[{"ns":"bluepine","title":"Bluepine Supply","version":"2.5","summary":"This product is a B2B office-supply store. A buyer signs in, browses an eight-product catalog by category and price, builds a cart with an optional promo code, checks out with a name, email and delivery address, reviews paginated order history and manages account settings. The same pricing and order rules are exposed through a REST API.","problem":"Procurement buyers need to order everyday supplies quickly and trust that every price, discount, tax amount and order number on screen is exact.","personas":[{"name":"Office buyer","need":"Find items, see an exact total with tax and discount, and place an order with clear confirmation."},{"name":"Accounts payable contact","need":"Keep a billing email on file and look back at past orders."},{"name":"Integrator","need":"Price carts and place orders through an API that agrees with the website to the cent."}],"goals":["Line totals, sub-total, discount, tax and total always reconcile to the cent, in the UI and the API.","Invalid input is rejected with a specific, correct message before an order is created.","Browsing (filter, sort, pages) always shows exactly the items the control promises."],"features":[{"id":"AUTH","title":"Sign-in","summary":"Buyer login and logout.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"A valid sign-in opens the Catalog with all eight products and the buyer’s name in the header.","accept":["The sign-in page shows the store name as its heading and a hint about the demo account","After sign-in: heading \"Catalog\", header \"Hi, Buyer\", navigation Catalog / Cart (0) / Orders / Settings","8 product cards, each with name, \"<Category> · <SKU>\", price and \"Add to cart\""]},{"id":"FR-AUTH-02","weight":"Must","text":"A failed sign-in shows one generic message for a wrong password or a wrong-case username, and the form stays.","accept":["Alert reads exactly \"Incorrect username or password.\"","Usernames are case-sensitive"]},{"id":"FR-AUTH-03","weight":"Could","text":"Sign out returns to the sign-in form and the session does not survive a reload."}]},{"id":"CAT","title":"Catalog","summary":"Eight products with category filter, price sort and a support link.","reqs":[{"id":"FR-CAT-01","weight":"Must","text":"The Category filter shows exactly the products of the chosen category.","accept":["Paper (3): A4 copy paper, Sticky notes, Spiral notebook","Pens (2): Gel pens 10 pack, Highlighters 6 colours","Desk (3): Desk stapler, Monitor stand, Desk organiser","All: 8 products"]},{"id":"FR-CAT-02","weight":"Must","text":"Price sorts compare prices numerically and \"high to low\" is the exact reverse of \"low to high\".","accept":["Low to high: Sticky notes $4.25, Highlighters $5.75, Gel pens $6.99, A4 copy paper $9.49, Spiral notebook $12.50, Desk stapler $14.99, Desk organiser $21.50, Monitor stand $32.00","Desk with high to low: Monitor stand, Desk organiser, Desk stapler"]},{"id":"FR-CAT-03","weight":"Could","text":"The footer \"Email support\" link opens a message to the support address (support@ at the store own example.com domain); the API reports the same address as supportEmail."}]},{"id":"CRT","title":"Cart, promo code and tax","summary":"Quantities, line totals, SAVE10 promo code and 8% tax.","reqs":[{"id":"FR-CRT-01","weight":"Must","text":"The navigation badge counts units (not lines) and updates immediately on every page, including the Catalog.","accept":["Adding \"Gel pens, 10 pack\" shows status \"Gel pens, 10 pack added to cart.\" and \"Cart (1)\"","Adding it again and then \"Desk stapler\" shows \"Cart (3)\" while still on Catalog"]},{"id":"FR-CRT-02","weight":"Must","text":"Each line total is price × quantity, the Sub-total row sums every line, tax is 8% of the discounted sub-total rounded to the nearest cent, and Total = Sub-total − Discount + Tax.","accept":["2 × A4 copy paper $9.49 + 1 × Monitor stand $32.00: lines $18.98 and $32.00, Sub-total $50.98, Tax (8%) $4.08, Total $55.06","Tax on a $45.88 amount is $3.67 (not rounded up)"]},{"id":"FR-CRT-03","weight":"Must","text":"Promo code SAVE10 takes 10% off the sub-total before tax; codes are case-insensitive.","accept":["Entering \"save10\" in the promo-code field on the $50.98 basket: Discount (SAVE10) -$5.10, Tax (8%) $3.67, Total $49.55"]},{"id":"FR-CRT-04","weight":"Should","text":"An unknown promo code is rejected and removes any discount already applied.","accept":["\"SAVE50\" shows an alert of the form \"That <promo> code is not valid.\"; the Discount row disappears and Total returns to $55.06"]},{"id":"FR-CRT-05","weight":"Should","text":"Changing a quantity or removing a line recalculates everything; quantity has a minimum of 1.","accept":["A4 copy paper quantity 1: line $9.49, Sub-total $41.49, Tax $3.32, Total $44.81, \"Cart (2)\"","Quantity 0 snaps back to 1","Removing every line shows \"Your cart is empty.\" and \"Cart (0)\""]},{"id":"FR-CRT-06","weight":"Could","text":"The cart is kept for the browser tab across reloads (session storage) while the user stays signed in."}]},{"id":"CHK","title":"Checkout and confirmation","summary":"Validate delivery details, place the order, confirm.","reqs":[{"id":"FR-CHK-01","weight":"Must","text":"Checkout requires full name, a well-formed email and a delivery address, reporting each separately, and creates no order while any is invalid.","accept":["Checkout shows \"3 item(s) · $55.06 including tax\"","Empty form: \"Enter your full name.\", \"Enter a valid email address.\", \"Enter a delivery address.\"; fields are aria-invalid=\"true\"","Email \"ada@example\" (no domain dot) leaves only \"Enter a valid email address.\""]},{"id":"FR-CHK-02","weight":"Must","text":"A valid order shows an \"Order confirmed\" page, empties the cart and appears first in Orders.","accept":["Confirmation reads \"Order BP-1241 was placed: 3 item(s), $49.55.\" for the promo-code basket","Navigation reads \"Cart (0)\"","Orders first row: BP-1241 · 2026-10-02 · 3 · $49.55, \"Page 1 of 3\""]},{"id":"FR-CHK-03","weight":"Could","text":"Direct visits to confirmation or checkout without an order or items are safe.","accept":["#/confirmation: \"There is no recent order to show.\" with \"Continue shopping\"","#/checkout with an empty cart: \"Your cart is empty.\"","Unknown routes show the Catalog"]},{"id":"FR-CHK-04","weight":"Could","text":"Orders are placed without payment; no payment details are collected."}]},{"id":"ORD","title":"Order history","summary":"Twelve seeded orders, newest first, five per page.","reqs":[{"id":"FR-ORD-01","weight":"Must","text":"Orders list newest first, five per page, and each page shows its own rows.","accept":["Page 1: BP-1240 2026-09-28 1 $19.99; BP-1239 2026-09-26 2 $27.34; BP-1238 3 $34.69; BP-1237 4 $42.04; BP-1236 1 $49.39; \"Page 1 of 3\"; \"Previous\" disabled","Page 2: BP-1235 … BP-1231, button 2 has aria-current=\"page\"","Page 3: BP-1230 and BP-1229 only; \"Next\" disabled"]}]},{"id":"SET","title":"Settings","summary":"Display name and billing email.","reqs":[{"id":"FR-SET-01","weight":"Should","text":"Saving settings confirms, updates the header greeting and persists across reloads.","accept":["Defaults: Display name \"Buyer\" and a seeded billing email","Saving \"Ada\" shows \"Settings saved.\" and header \"Hi, Ada\"; after reload both values are kept"]},{"id":"FR-SET-02","weight":"Should","text":"Billing email accepts up to 40 characters, in the field and in the API.","accept":["A 31-character address is saved in full","The field keeps at most 40 characters (maxlength 40)","API: 40 characters returns 200; 41 returns 422 \"Billing email must be at most 40 characters.\""]}]},{"id":"API","title":"REST API","summary":"Bearer-token API mirroring the website.","reqs":[{"id":"FR-API-01","weight":"Should","text":"Protected endpoints require a bearer token; login rejects bad credentials; catalog filters and sorts server-side.","accept":["GET /orders without a token returns 401","POST /auth/login with a wrong password returns 401 \"Invalid credentials\"; a valid login returns 200 with token and user","GET /products?category=Desk&sort=price-desc returns BP-302, BP-303, BP-301 and supportEmail"]},{"id":"FR-API-02","weight":"Must","text":"The cart-quote and promo-validation endpoints use integer cents and agree with the UI.","accept":["POST /cart/quote with BP-101 ×2, BP-302 ×1 and promo code \"save10\": items 3, sub-total 5098, discount 510, tax 367, total 4955","Promo code \"SAVE50\" returns 422 with the same \"... code is not valid.\" message as the website","Validating \" save10 \" (padded, lower-case) returns valid true, code \"SAVE10\", percent 10"]},{"id":"FR-API-03","weight":"Must","text":"Order endpoints page the history, validate input and number new orders per session.","accept":["GET /orders: total 12, pages 3, pageSize 5, first BP-1240 placed 2026-09-28, items 1, total 1999; page=3 gives BP-1230, BP-1229","POST /orders with empty fields returns 422 with errors for name, email, address and lines (\"Your cart is empty.\")","A valid order returns 201 id BP-1241, placed 2026-10-02, items 3, total 4955","GET /orders/BP-1241 returns 200; /orders/BP-9999 returns 404 not_found"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"All API amounts are integer cents and displayed amounts are dollars with exactly two decimals; both always agree."},{"id":"NFR-02","weight":"Should","text":"Invalid checkout fields are exposed to assistive technology with aria-invalid and the current pagination page with aria-current=\"page\"."}],"glossary":[{"term":"SAVE10","meaning":"The only valid promo code; 10% off the sub-total, applied before tax."},{"term":"Units","meaning":"Sum of quantities across cart lines; this is what the Cart badge shows."}],"outOfScope":["Payment: orders are placed without one","Email delivery"]},{"ns":"meridian","title":"Meridian Care Service","version":"1.0","summary":"Meridian Care Service is a server-rendered healthcare appointment site. Visitors see a landing page and sign in with a demo account shown on the login page; signed-in users book a visit at one of three care centers, receive a confirmation, and review the appointments of their own browser session in History. A sidebar menu adapts to the sign-in state.","problem":"Patients need a simple way to request a visit and to see what they have booked, without anyone else being able to see or create appointments on their behalf.","personas":[{"name":"Visitor","need":"Understand the service and find the way to sign in."},{"name":"Patient","need":"Book a visit with a facility, program and date, get a clear confirmation and review earlier bookings."}],"goals":["Booking and history are reachable only after signing in.","The confirmation repeats exactly what the patient chose.","Each browser session sees only its own appointments."],"features":[{"id":"LND","title":"Landing page","summary":"Public home page.","reqs":[{"id":"FR-LND-01","weight":"Must","text":"An anonymous visitor sees the hero and a way to sign in, but no booking form.","accept":["Heading \"Meridian Care Service\", sub-heading \"Care That Fits Your Schedule\", button \"Make Appointment\" leading to the login form","No \"Book Appointment\" button is rendered","The three centers are listed: Tokyo, Hongkong and Seoul Meridian Care Center","The footer shows the service name, a postal address and a copyright line"]}]},{"id":"AUT","title":"Sign in and out","summary":"Demo account login, failures, access control and logout.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"The login page shows the demo account in read-only fields; signing in with it opens the booking form.","accept":["Heading \"Login\" and \"Please login to make appointment.\"","Valid sign-in redirects to /meridian/#appointment with heading \"Make Appointment\" and the booking form"]},{"id":"FR-AUT-02","weight":"Must","text":"Any failed sign-in (wrong password, wrong case in the username, empty fields) shows the same message and keeps the login form.","accept":["\"Login failed! Please ensure the username and password are valid.\"","The username is case-sensitive","Both fields empty gives the same message"]},{"id":"FR-AUT-03","weight":"Must","text":"History and appointment creation require a signed-in session; otherwise the request is redirected to the login form and nothing is created.","accept":["Opening /meridian/history.php without a session redirects to /meridian/profile.php#login","Posting to /meridian/appointment.php without a session returns 302 to the login form and creates no appointment"]},{"id":"FR-AUT-04","weight":"Should","text":"Logout ends the session: the booking form and History are no longer reachable.","accept":["After Logout the browser returns to /meridian/ with no booking form","The menu then lists only Home and Login","History redirects to the login form"]}]},{"id":"MNU","title":"Sidebar menu","summary":"Hamburger menu with state-dependent links.","reqs":[{"id":"FR-MNU-01","weight":"Could","text":"The menu button opens a sidebar that can be closed; its links depend on the sign-in state.","accept":["Signed out: Meridian Care, Home, Login","Signed in: Meridian Care, Home, History, Profile, Logout","\"Close menu\" hides the sidebar","Profile shows \"Under construction.\" and a \"Logout\" button"]}]},{"id":"BKG","title":"Book an appointment","summary":"Booking form, date picker and confirmation.","reqs":[{"id":"FR-BKG-01","weight":"Should","text":"The booking form starts with documented defaults.","accept":["Facility \"Tokyo Meridian Care Center\" (options Tokyo, Hongkong, Seoul)","\"Apply for hospital readmission\" unchecked","Healthcare Program \"Medicare\" selected (Medicare, Medicaid, None)","Visit Date empty with placeholder \"mm/dd/yyyy\"; Comment empty"]},{"id":"FR-BKG-02","weight":"Must","text":"Booking shows a confirmation that repeats every chosen value.","accept":["Seoul Meridian Care Center, readmission ticked, Medicaid, 12/24/2026, comment \"Follow-up visit\"","Heading \"Appointment Confirmation\" and \"Please be informed that your appointment has been booked as following:\"","Rows: Facility Seoul Meridian Care Center; Apply for hospital readmission Yes; Healthcare Program Medicaid; Visit Date 12/24/2026; Comment Follow-up visit","\"Go to Homepage\" returns to /meridian/ with the booking form"]},{"id":"FR-BKG-03","weight":"Should","text":"Only the visit date is required; omitted fields take the defaults.","accept":["Visit Date 01/15/2027 only: Tokyo Meridian Care Center; readmission No; program Medicare; empty comment"]},{"id":"FR-BKG-04","weight":"Must","text":"A visit date is required, in the browser and on the server; an empty date creates no appointment.","accept":["Submitting with Visit Date empty shows the browser required-field message and no confirmation","A direct post with an empty date returns 302 to /meridian/#appointment and adds nothing to History"]},{"id":"FR-BKG-05","weight":"Should","text":"The visit date must be a real calendar date in mm/dd/yyyy; values that match the pattern but do not exist are rejected and not booked.","accept":["99/99/2026 is rejected: no confirmation and no entry in History"]},{"id":"FR-BKG-06","weight":"Could","text":"The calendar popup fills the visit date in mm/dd/yyyy.","accept":["Clicking Visit Date opens dialog \"Choose date\" with the current month, weekdays Su to Sa and today highlighted","\"Next month\" then day 15 closes the popup and fills mm/15/yyyy for the next month","Escape closes the popup and keeps the value"]},{"id":"FR-BKG-07","weight":"Should","text":"The comment is displayed as plain text everywhere; markup is escaped.","accept":["Comment \"<b>x</b>\" appears literally on the confirmation and in History, not bold"]}]},{"id":"HST","title":"History","summary":"Appointments of the current session.","reqs":[{"id":"FR-HST-01","weight":"Must","text":"History lists the current session’s appointments in booking order, each as a panel headed by its visit date.","accept":["Empty: heading \"History\" and \"No appointment.\"","After booking 12/24/2026 then 01/15/2027: panels \"12/24/2026\" then \"01/15/2027\", each with Facility, Apply for hospital readmission, Healthcare Program and Comment"]},{"id":"FR-HST-02","weight":"Must","text":"History is isolated per browser session: another session never sees these appointments.","accept":["A new browser context that signs in and opens History sees \"No appointment.\"","The first context still shows its panel \"12/24/2026\""]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"On a 375 px wide screen the hero heading shrinks, form labels sit left-aligned above their fields, nothing scrolls sideways, and the sidebar overlays the page and can be closed."}],"glossary":[{"term":"Session","meaning":"A browser cookie that carries the sign-in state and scopes appointments."}],"outOfScope":["Profile page content (under construction)","Email and phone links in the footer"]},{"ns":"drill-grounds","title":"Drill Grounds","version":"1.1","summary":"Drill Grounds is a set of classic web practice pages: an index of twelve examples including form authentication with flash messages, add/remove elements, checkboxes, a bare dropdown, inputs, key presses, JavaScript dialogs, multiple windows, hovers, a sortable table, dynamic loading and HTTP status codes. Markup is deliberately plain so controls can be driven directly.","problem":"Anyone learning or validating web automation needs small, predictable pages with well-known behaviours: waits, native dialogs, new tabs, sorting, sessions and flash messages.","personas":[{"name":"Automation learner","need":"Practise each interaction type on a page with a single, obvious behaviour."},{"name":"Tool evaluator","need":"Check that a tool handles waits, dialogs, windows, hover and session flows correctly."}],"goals":["Every example behaves exactly as its heading and instructions say.","Sessions and flash messages follow the classic one-shot pattern.","Timed elements appear after a delay of about 2.5 seconds."],"features":[{"id":"IDX","title":"Index","summary":"Landing page linking every example.","reqs":[{"id":"FR-IDX-01","weight":"Must","text":"The index lists all twelve examples and each link opens its page with a 200 response.","accept":["Title \"Drill Grounds\"; heading \"Welcome to drill-grounds\"; sub-heading \"Available Examples\"","Links: Add/Remove Elements, Checkboxes, Dropdown, Dynamic Loading, Form Authentication, Hovers, Inputs, JavaScript Alerts, Key Presses, Multiple Windows, Status Codes, Sortable Data Tables","Footer \"Powered by Drill Grounds\""]}]},{"id":"AUT","title":"Form authentication","summary":"Login page, secure area and flash messages.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Valid credentials open the secure area with a green, dismissible flash.","accept":["Login page heading \"Login Page\" with Username, Password and \"Login\"","URL /drill-grounds/secure; flash \"You logged into a secure area!\"; heading \"Secure Area\"; button \"Logout\"","The flash close button (x) removes the flash"]},{"id":"FR-AUT-02","weight":"Must","text":"Invalid credentials are refused with a red flash; the username is checked first (case-sensitive), then the password.","accept":["Unknown, differently cased or empty username: \"Your username is invalid!\"","Right username with a wrong password: \"Your password is invalid!\"","The login page is shown again"]},{"id":"FR-AUT-03","weight":"Must","text":"The secure area requires a session; anonymous access is redirected to the login page with a flash shown only once.","accept":["Flash \"You must login to view the secure area!\"","Reloading the login page shows no flash"]},{"id":"FR-AUT-04","weight":"Should","text":"Logout ends the session, returns to the login page and the secure area is protected again.","accept":["Flash \"You logged out of the secure area!\"","Opening /drill-grounds/secure redirects to login with \"You must login to view the secure area!\""]}]},{"id":"CTL","title":"Native controls","summary":"Add/remove, checkboxes, dropdown, inputs and keys.","reqs":[{"id":"FR-CTL-01","weight":"Should","text":"Each click of \"Add Element\" adds a \"Delete\" button (class added-manually); each Delete removes itself.","accept":["3 clicks give three buttons; deleting one leaves two; deleting all leaves none and \"Add Element\" still works"]},{"id":"FR-CTL-02","weight":"Should","text":"Two checkboxes start unchecked and checked and toggle on click; state is not persisted.","accept":["\"checkbox 1\" unchecked, \"checkbox 2\" checked; after clicking both the reverse; reload restores the start state"]},{"id":"FR-CTL-03","weight":"Should","text":"The dropdown is a bare select (id \"dropdown\", no label or ARIA) with a disabled placeholder; choosing an option sets its value.","accept":["Heading \"Dropdown List\"; placeholder \"Please select an option\" is disabled and cannot be re-selected","\"Option 2\" gives value \"2\""]},{"id":"FR-CTL-04","weight":"Could","text":"The number field accepts digits, signs and arrow keys only.","accept":["12 then ArrowUp twice = 14","-3 then ArrowDown = -4","Typing \"abc\" leaves the field empty"]},{"id":"FR-CTL-05","weight":"Should","text":"The key-press page reports the last key pressed in upper case.","accept":["\"a\" gives \"You entered: A\"; Enter gives \"You entered: ENTER\"; Escape gives \"You entered: ESCAPE\"; Shift gives \"You entered: SHIFT\""]}]},{"id":"BRW","title":"Browser features","summary":"Dialogs, windows and hovers.","reqs":[{"id":"FR-BRW-01","weight":"Must","text":"Each JavaScript alert, confirm and prompt shows its native dialog and reports the outcome in Result.","accept":["Alert (text \"I am a JS Alert\"): \"You successfully clicked an alert\"","Confirm: dismiss \"You clicked: Cancel\"; accept \"You clicked: Ok\"","Prompt: typing hello gives \"You entered: hello\"; dismiss gives \"You entered: null\""]},{"id":"FR-BRW-02","weight":"Should","text":"\"Click Here\" opens a second tab that can be switched to and closed without affecting the first.","accept":["New tab /drill-grounds/windows/new titled \"New Window\" with heading \"New Window\"","Original tab still shows \"Opening a new window\""]},{"id":"FR-BRW-03","weight":"Could","text":"Each of three avatars shows its caption and a \"View profile\" link only while hovered.","accept":["Second avatar caption \"name: user2\"; other captions hidden; hidden again after moving away"]},{"id":"FR-BRW-04","weight":"Could","text":"Each \"View profile\" link opens a profile page.","accept":["/drill-grounds/users/1 responds 200"]}]},{"id":"TBL","title":"Sortable table","summary":"Header sorting with direction toggle.","reqs":[{"id":"FR-TBL-01","weight":"Should","text":"Clicking a header sorts the rows and a second click reverses the order; text sorts alphabetically and Due sorts numerically.","accept":["Table #table1 columns Last Name, First Name, Email, Due, Web Site, Action; initial rows Smith, Bach, Doe, Conway","Last Name ascending: Bach, Conway, Doe, Smith; descending: Smith, Doe, Conway, Bach","Due ascending: $50.00, $50.00, $51.00, $100.00; descending starts with Doe $100.00 then Bach $51.00"]}]},{"id":"DYN","title":"Dynamic loading","summary":"Waits for delayed content.","reqs":[{"id":"FR-DYN-01","weight":"Should","text":"Example 1 contains a hidden element that becomes visible after the loader.","accept":["Heading \"Example 1: Element on page that is hidden\"; #finish exists but hidden","Start shows \"Loading... \" and hides Start; after about 2.5 s \"Hello World!\" is visible and the loader hidden"]},{"id":"FR-DYN-02","weight":"Should","text":"Example 2 renders its element only after the loader finishes.","accept":["Heading \"Example 2: Element rendered after the fact\"; no #finish before Start","\"Hello World!\" appears after about 2.5 s"]},{"id":"FR-DYN-03","weight":"Could","text":"An example number that does not exist responds 404.","accept":["/drill-grounds/dynamic_loading/3 returns HTTP 404"]}]},{"id":"STA","title":"Status codes","summary":"Pages returning chosen HTTP statuses.","reqs":[{"id":"FR-STA-01","weight":"Should","text":"Each status link returns the status it names with a body naming it and a link back; unsupported codes return 404.","accept":["Links 200, 301, 404, 500 return HTTP 200, 301, 404, 500","Body \"This page returned a <code> status code.\"","/drill-grounds/status_codes/418 returns 404 and the body says 404"]}]}],"nfr":[],"glossary":[{"term":"Flash","meaning":"A one-shot message shown after a redirect and gone on the next page view."}],"outOfScope":["Visual styling","Examples not listed on the index"]},{"ns":"example","title":"Example Domain","version":"1.0","summary":"Example Domain is the reserved documentation domain page: one short paragraph, translations of it, an icon and a single \"Learn more\" link to an explanation page. It deliberately has no navigation, logo or search.","problem":"Writers of documentation need a domain they can use in examples without permission, and readers who land on it need a short, unambiguous explanation of why it exists.","personas":[{"name":"Documentation reader","need":"Understand at a glance that this domain is only for examples and find out more."},{"name":"Translator or screen-reader user","need":"Get each language version correctly tagged."}],"goals":["The page stays minimal: one heading, one paragraph, one link.","The same page serves .com, .net and .org variants."],"features":[{"id":"PAG","title":"Page content","summary":"Title, icon, paragraph, translations and structure.","reqs":[{"id":"FR-PAG-01","weight":"Must","text":"The page at /example/ has the document title \"Example Domain\", an icon labelled \"Example icon\", the paragraph and exactly one link.","accept":["Paragraph: \"This domain is for use in documentation examples without needing permission. Avoid use in operations.\"","Exactly one link, \"Learn more\""]},{"id":"FR-PAG-02","weight":"Should","text":"Translations of the paragraph are listed in a fixed order and each declares its language.","accept":["Five items with lang es, fr, de, vi, ja in that order","The vi item reads \"Tên miền này dùng cho ví dụ trong tài liệu mà không cần xin phép. Tránh dùng trong vận hành.\"","The html element has lang=\"en\""]},{"id":"FR-PAG-03","weight":"Should","text":"The page has no site chrome and a single heading.","accept":["No nav, header or search box","Exactly one h1 \"Example Domain\"; content sits in a single main element"]}]},{"id":"VAR","title":"Domain variants","summary":"The ?domain= parameter.","reqs":[{"id":"FR-VAR-01","weight":"Should","text":"The .com, .net and .org variants share the same content and differ only in the description meta.","accept":["?domain=net: description \"example.net\"","?domain=org: description \"example.org\"","Any other value, or none, falls back to \"example.com\""]}]},{"id":"LNK","title":"Learn more link","summary":"The single outbound link.","reqs":[{"id":"FR-LNK-01","weight":"Must","text":"\"Learn more\" opens the example-domains explanation page.","accept":["URL /registry/help/example-domains, heading \"Example Domains\", document title \"Example Domains\"","Back returns to the documentation page"]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"On a 375 px wide phone the content keeps a 16 px side gutter and does not scroll sideways; on desktop the content column is centred and at most 640 px wide."}],"outOfScope":["The linked reference site (covered by its own product spec)"]},{"ns":"registry","title":"GNR","version":"1.0","summary":"Global Names Registry is a small reference site: main navigation, an explanation of reserved example domains, a Root Zone Database of top-level domains with a type filter and per-domain delegation records, and site search.","problem":"Readers following a documentation link need authoritative, navigable reference content about reserved domains and the top-level domains in the sample registry.","personas":[{"name":"Documentation reader","need":"Understand reserved domains and follow the references."},{"name":"Registry researcher","need":"Look up a top-level domain, its type and manager, by browsing, filtering or searching."}],"goals":["Every link leads to a real page.","The domain list is complete and filters are exact.","User-supplied search text is never interpreted as markup."],"features":[{"id":"NAV","title":"Home and navigation","summary":"Header, search box and top-level sections.","reqs":[{"id":"FR-NAV-01","weight":"Must","text":"The home page shows the heading \"Global Names Registry\" with links Domain Names, Number Resources and Protocol Assignments, and a header with every top-level section.","accept":["Logo link \"Homepage\"; nav \"Main\" with Domains, Protocols, Numbers, About; search box \"Search the registry\" with \"Go\"","Protocols, Numbers and About open pages with those headings and \"Reference content.\"","The logo returns to /registry/"]},{"id":"FR-NAV-02","weight":"Could","text":"On a 375 px wide viewport the logo and nav stay on top and the search input spans the full width below, without horizontal scroll."}]},{"id":"HLP","title":"Example-domains help","summary":"Explanation of reserved example domains.","reqs":[{"id":"FR-HLP-01","weight":"Must","text":"The help page explains the reserved domains and lists further reading.","accept":["Heading \"Example Domains\"; text mentions RFC 2606 and RFC 6761 and says the domains \"are not available for registration or transfer\"","Further Reading links \"Registry-managed Reserved Domains\" and \"Root Zone Database\"; footer \"Last revised 2017-05-13.\"","\"Registry-managed Reserved Domains\" opens a table of 7 rows (example, test, localhost, invalid, example.com, example.net, example.org), each \"Reserved for documentation and testing\""]},{"id":"FR-HLP-02","weight":"Should","text":"Every link in the help page content resolves to a real page.","accept":["The RFC 2606 and RFC 6761 links return 200, not 404"]}]},{"id":"TLD","title":"Root Zone Database","summary":"Table of the 20 sample top-level domains with a type filter.","reqs":[{"id":"FR-TLD-01","weight":"Must","text":"The Root Zone Database lists every top-level domain with its type and manager.","accept":["Columns Domain, Type, TLD Manager; Type select initially \"All\"","20 rows from .aaa to .vn in alphabetical order",".com row: generic, Meridian Global Registry Services"]},{"id":"FR-TLD-02","weight":"Should","text":"The Type select reloads the table with only that type and keeps its selection.","accept":["sponsored: .edu, .gov (URL has ?type=sponsored)","country-code: 11 rows .ac .ad .ae .au .de .io .jp .sg .uk .us .vn","generic: 6 rows .aaa .app .com .dev .net .org","generic-restricted: 1 row .biz","All: 20 rows"]},{"id":"FR-TLD-03","weight":"Could","text":"An unknown type value shows an empty table with the select on \"All\"; it is not an error."},{"id":"FR-TLD-04","weight":"Could","text":"Each domain links to its delegation record, and a missing record is a real 404 inside the site chrome.","accept":[".vn opens /registry/domains/root/db/vn.html, heading \"Delegation Record for .VN\", sponsoring organisation \"Ministry of Information and Communications of Socialist Republic of Viet Nam\", \"Type: country-code\"","An unknown domain returns HTTP 404, heading \"Not Found\", header nav still present"]}]},{"id":"SRC","title":"Site search","summary":"Search over domain names and managers.","reqs":[{"id":"FR-SRC-01","weight":"Should","text":"Search matches domain names and TLD managers case-insensitively and lists the matching domains.","accept":["\"registry\": heading \"Search results for “registry”\" and 8 results .app .biz .com .dev .jp .net .org .us","\"UK\": one result .uk, opening the delegation record for .UK (sponsoring organisation \"Albion Names Ltd\")"]},{"id":"FR-SRC-02","weight":"Should","text":"A search that matches nothing, including an empty query, says \"No results found.\"","accept":["\"zzzz\": heading \"Search results for “zzzz”\" and \"No results found.\"","Empty query: heading \"Search results for “”\" and \"No results found.\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"The search query is echoed as text and escaped; markup entered in the query is never rendered.","accept":["?q=<b>x</b> shows the literal text <b>x</b>; nothing is bold"]}],"outOfScope":["Protocols, Numbers and About content (placeholders)","Registering or transferring domains"]},{"ns":"news","title":"Multi-language News","version":"2.0","summary":"Multi-language News is a news portal in eight languages — Tiếng Việt, English, 中文, Français, ไทย, 日本語, Deutsch and العربية (right-to-left). Each language has a home page with a top story and section blocks, ten section pages, article pages with related stories, search tuned to its script, local date formats and a language switcher. The catalogue is 28 Vietnamese articles, 8 of which are translated into every other language; a small JSON API serves all of it.","problem":"Readers want today's news grouped by topic, in their own language and script, and want to find stories without typing every accent, tone mark or script variant exactly.","personas":[{"name":"News reader","need":"See the newest story first, browse by section and read an article with related stories."},{"name":"Searcher","need":"Find stories without having to type tone marks exactly."},{"name":"International reader","need":"Switch to English, Chinese, French, Thai, Japanese, German or Arabic and stay on the same story, with dates and layout that feel native."},{"name":"Integrator","need":"Fetch sections, articles and languages over a simple API."}],"goals":["Stories are ordered newest first and every section holds exactly its own stories.","Search is forgiving of accents, case and script variants, and never renders user input as markup.","Article text is shown exactly as written, with proper-noun capitals intact.","Every language gets translated chrome, local dates and the right text direction, and the switcher never loses the reader's place."],"features":[{"id":"HOM","title":"Home page","summary":"Header, section navigation, top story and section blocks.","reqs":[{"id":"FR-HOM-01","weight":"Must","text":"The home page shows the header, the section nav, the top story, side headlines, four section blocks and the footer.","accept":["Title \"Multi-language News - Tin tức Việt Nam mỗi ngày\"; html lang=\"vi\"","Header: logo link, current date as \"<weekday>, d/m/yyyy, hh:mm (GMT+7)\", search box \"Tìm kiếm\"","Nav \"Chuyên mục\": home icon then Thời sự, Thế giới, Kinh doanh, Khoa học công nghệ, Giải trí, Thể thao, Pháp luật, Giáo dục, Sức khỏe, Du lịch","Footer \"© 2026. Toàn bộ bản quyền thuộc Multi-language News (mô phỏng).\""]},{"id":"FR-HOM-02","weight":"Must","text":"Stories are ordered newest first: the large top story is the newest article, followed by three side headlines.","accept":["Top story: \"Hà Nội phân luồng giao thông phục vụ thi công cầu Trần Hưng Đạo\"","Side headlines: \"TP HCM mở rộng tuyến buýt điện…\", \"Cao tốc Bắc - Nam đoạn qua Khánh Hòa…\", \"Bộ Giao thông Vận tải đề xuất…\"","Section blocks Thời sự, Thế giới, Kinh doanh, Khoa học công nghệ show two stories each"]}]},{"id":"SEC","title":"Section pages","summary":"One page per section.","reqs":[{"id":"FR-SEC-01","weight":"Should","text":"A section page lists only its own stories and marks the section active in the nav.","accept":["\"Thể thao\" opens /news/the-thao, title \"Thể thao - Multi-language News\", heading \"Thể thao\", exactly 2 stories","The active nav link has aria-current=\"page\" and is bold"]},{"id":"FR-SEC-02","weight":"Should","text":"Story counts per section are fixed and total 28.","accept":["Thời sự 6; Thế giới 4; Kinh doanh 4; every other section 2","GET /api/news/articles reports total 28"]},{"id":"FR-SEC-03","weight":"Could","text":"An unknown section or article id returns a real HTTP 404 page inside the site chrome.","accept":["Heading \"404 - Không tìm thấy trang\"; header and nav still present","Applies to /news/khong-ton-tai and /news/abc-9999999.html"]}]},{"id":"ART","title":"Article pages","summary":"Story detail, canonical URLs and related stories.","reqs":[{"id":"FR-ART-01","weight":"Must","text":"An article page shows breadcrumb, time, headline, bold lead, body and related stories.","accept":["URL form /news/<slug>-<id>.html; title \"<headline> - Multi-language News\"","Breadcrumb \"Thời sự\" links to /news/thoi-su; time \"Chủ nhật, 18/10/2026, 08:23 (GMT+7)\" for the top story","Body of three paragraphs","\"Tin liên quan\" shows three other stories from the same section, never the current one"]},{"id":"FR-ART-02","weight":"Should","text":"The article body reproduces proper nouns with their correct capitals; where the first paragraph quotes the headline, \"Hà Nội\" keeps its capital letters.","accept":["The first paragraph of the top story contains \"Hà Nội phân luồng giao thông…\" with \"Hà\" capitalised"]},{"id":"FR-ART-03","weight":"Could","text":"An article URL whose id is right but whose slug is wrong redirects to the canonical URL.","accept":["/news/wrong-slug-4800100.html redirects (301 or 302) to the canonical top-story URL"]}]},{"id":"SRH","title":"Search","summary":"Accent- and case-insensitive Vietnamese search.","reqs":[{"id":"FR-SRH-01","weight":"Must","text":"The header search lists matching stories and keeps the query in the box.","accept":["\"giao thông\" goes to /news/tim-kiem?q=giao+th%C3%B4ng with heading \"Kết quả tin tức cho từ khóa giao thông\"","6 matching stories; the search box still contains \"giao thông\""]},{"id":"FR-SRH-02","weight":"Should","text":"Search folds diacritics and đ and is case-insensitive.","accept":["\"giao thong\" and \"GIAO THONG\" return the same 6 stories as \"giao thông\"","\"duong\" and \"đường\" return the same 3 stories"]},{"id":"FR-SRH-03","weight":"Could","text":"A search that matches nothing, including an empty query, shows \"Không tìm thấy kết quả phù hợp.\" and no stories."}]},{"id":"LOC","title":"Languages","summary":"Eight locales, switcher, hreflang, local dates, RTL and per-script search.","reqs":[{"id":"FR-LOC-01","weight":"Must","text":"Every page has a language bar listing Tiếng Việt, English, 中文, Français, ไทย, 日本語, Deutsch and العربية by native name. Vietnamese lives at /news/; every other language at /news/<code>/ with fully translated chrome.","accept":["Each link carries hreflang and lang; the current language has aria-current","/news/en/ has html lang=\"en\", title \"Multi-language News - Daily news in your language\", nav Current affairs … Travel and footer \"© 2026 Multi-language News (simulated). All rights reserved.\"","The top story is the newest translated story; section blocks are the first four sections that have stories","/news/en redirects 301 to /news/en/; /news/vi/ redirects to /news/; an unknown code is 404"]},{"id":"FR-LOC-02","weight":"Must","text":"The language links keep the reader's place: on a translated article they open the same article, on a section the same section, on search the same query.","accept":["Latin-script languages (en, fr, de) use /news/<code>/<slug>-<id>.html; zh, th, ja and ar use /news/<code>/<id>.html","From the top story, \"Français\" opens /news/fr/hanoi-devie-la-circulation-pour-les-travaux-du-pont-tran-hung-dao-4800100.html and \"日本語\" opens /news/ja/4800100.html","/news/en/sports → \"Deutsch\" → /news/de/sports"]},{"id":"FR-LOC-03","weight":"Should","text":"Article pages declare their language versions with <link rel=\"alternate\" hreflang>, plus x-default pointing at the Vietnamese original.","accept":["Story 4800100: alternates vi, en, zh-Hans, fr, th, ja, de, ar and x-default","A Vietnamese-only story has only vi and x-default"]},{"id":"FR-LOC-04","weight":"Should","text":"Publication times use each language's own date convention, in GMT+7.","accept":["en \"Sunday, October 18, 2026, 8:23 AM (GMT+7)\"","fr \"dimanche 18 octobre 2026, 08:23 (GMT+7)\"; de \"Sonntag, 18. Oktober 2026, 08:23 (GMT+7)\"","zh \"2026年10月18日 星期日 08:23 (GMT+7)\"; ja \"2026年10月18日(日) 08:23 (GMT+7)\"","th \"วันอาทิตย์ที่ 18 ตุลาคม 2569 เวลา 08:23 น. (GMT+7)\" (Buddhist era)","ar \"الأحد، ١٨ أكتوبر ٢٠٢٦، ٠٨:٢٣ (GMT+7)\" (Arabic-Indic digits)"]},{"id":"FR-LOC-05","weight":"Must","text":"Every Arabic page, error pages included, is rendered right-to-left with the layout mirrored.","accept":["html lang=\"ar\" dir=\"rtl\" on home, section, article, search and 404 pages","Logo on the right, search on the left, side headlines to the left of the top story"]},{"id":"FR-LOC-06","weight":"Should","text":"A story requested in a language it is not translated into returns HTTP 404 and links to the languages it does exist in.","accept":["/news/en/4800102.html: 404, \"404 - Page not found\", \"This story is available in: Tiếng Việt\" linking to the Vietnamese URL","Vietnamese section slugs under another language (/news/en/the-thao) are 404"]},{"id":"FR-LOC-07","weight":"Should","text":"Search folds what each script's readers leave out: case and accents for Latin, typographic apostrophes and ligatures for French, ß for German, full/half-width forms for CJK, and tashkeel, tatweel and hamza variants for Arabic.","accept":["en \"AIRPORT\" and fr \"aeroport\" find the Tan Son Nhat airport story; fr \"l'ONU\" finds \"L’ONU appelle…\"","de \"Strasse\" finds \"Hanoi sperrt Straßen…\"","ja half-width \"ﾊﾉｲ\" finds \"ハノイ、…\"","ar \"تحول\" and \"الامم\" find \"تحوّل\" and \"الأمم\""]},{"id":"FR-LOC-08","weight":"Could","text":"German search treats the transliterations ue, oe and ae as ü, ö and ä.","accept":["de \"fuer\" returns the same three stories as \"für\""]},{"id":"FR-LOC-09","weight":"Should","text":"Outside Vietnamese, section pages use English slugs, list only translated stories and show a localised empty message when a section has none.","accept":["/news/en/sports: title \"Sports - Multi-language News\", 1 story","/news/en/law: \"No stories in this section yet.\"; /news/de/law: \"In diesem Ressort gibt es noch keine Artikel.\""]}]},{"id":"API","title":"Articles API","summary":"JSON endpoints for sections, articles and languages.","reqs":[{"id":"FR-API-01","weight":"Could","text":"GET /api/news/sections lists 10 sections; GET /api/news/articles supports section and accent-insensitive q filters, combined.","accept":["section=the-thao: total 2","section=kinh-doanh&q=gia: total 2","Each article has id, section, title, lead, body, time and url (/news/<slug>-<id>.html)"]},{"id":"FR-API-02","weight":"Could","text":"GET /api/news/locales lists the eight languages; articles and sections accept ?lang=<code> (default vi) and reject unknown codes.","accept":["locales: code, tag, name, dir (ar \"rtl\"), home, search and article count (vi 28, others 8)","articles?lang=ja: total 8; each item adds lang and translations","sections?lang=fr: English slugs with French names","lang=xx: HTTP 400 {error:\"validation\"}"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Search input is echoed as text and escaped, both in the heading and the search box; markup is never rendered.","accept":["?q=<b>x</b> shows the literal <b>x</b>"]},{"id":"NFR-02","weight":"Could","text":"The section nav stays on top while scrolling. At 375 px the top story stacks into one column, the header date is hidden, the nav scrolls sideways inside itself and the page has no horizontal scroll."}],"outOfScope":["Images (thumbnails are colour blocks)","Comments, login, video","Machine translation — only 8 stories are translated"]},{"ns":"provingground","title":"Proving Ground","version":"1.0","summary":"Proving Ground is a small practice site for people learning test automation. It offers a practice login page with inline errors, a success page with log out, a practice index, placeholder site pages and a static courses table. Login is validated in the browser only, after a short delay, and needs no account.","problem":"Learners need a stable, predictable target where positive, negative and boundary behaviour is documented exactly, so they can practise writing reliable checks.","personas":[{"name":"Learner","need":"A page with clear, repeatable success and error behaviour to practise login and table checks."},{"name":"Instructor","need":"Fixed content (messages, headings, table rows) that exercises can assert against."}],"goals":["Every message, heading and table cell is stable and exactly as documented.","Login rules are simple and deterministic: exact match, fixed check order.","All navigation targets resolve."],"features":[{"id":"LGN","title":"Practice login","summary":"Login form validated in the browser, with a hint showing the practice credentials.","reqs":[{"id":"FR-LGN-01","weight":"Must","text":"The login page presents a labelled form and the practice credentials; submitting the credentials shown leads to the success page.","accept":["Path /provingground/practice-test-login/, heading \"Test login\", hint \"Username: <name> Password: <password>\" (values as displayed on the page)","Valid credentials redirect to /provingground/logged-in-successfully/ with heading \"Logged In Successfully\"","Tab title \"Logged In Successfully | Proving Ground\""]},{"id":"FR-LGN-02","weight":"Must","text":"A wrong username shows the alert \"Your username is invalid!\" and the page does not navigate.","accept":["Username \"incorrectUser\" with the correct password: red alert \"Your username is invalid!\"; URL unchanged"]},{"id":"FR-LGN-03","weight":"Must","text":"A wrong password with a correct username shows the alert \"Your password is invalid!\" and the page does not navigate.","accept":["Correct username with password \"incorrectPassword\": red alert \"Your password is invalid!\"; URL unchanged"]},{"id":"FR-LGN-04","weight":"Should","text":"The username is checked before the password: when both are wrong, or both empty, only the username message is shown.","accept":["Wrong username and wrong password: \"Your username is invalid!\" only","Both fields empty: \"Your username is invalid!\""]},{"id":"FR-LGN-05","weight":"Should","text":"Credentials must match exactly: comparison is case-sensitive and values are not trimmed.","accept":["Username with a capital first letter: \"Your username is invalid!\"","Password in lower case: \"Your password is invalid!\"","Username with a trailing space: \"Your username is invalid!\""]},{"id":"FR-LGN-06","weight":"Could","text":"Each submit hides any previous alert and, after about 300 ms, shows the result of that attempt only; one message is shown at a time.","accept":["After a wrong username then a wrong password, the alert is briefly hidden and then reads \"Your password is invalid!\"; never both messages at once"]},{"id":"FR-LGN-07","weight":"Could","text":"Fields are labelled, the password is masked and the error container is an alert region hidden until a failed submit.","accept":["\"Username\" is a text input #username and \"Password\" a password input #password","#error has role \"alert\" and is not visible before a failed submit"]}]},{"id":"OUT","title":"Success page and log out","summary":"Confirmation after login with a way back.","reqs":[{"id":"FR-OUT-01","weight":"Should","text":"The success page greets the user and offers Log out, which returns to an empty login form.","accept":["Message \"Congratulations <username>. You successfully logged in!\" and a \"Log out\" button","Log out goes to /provingground/practice-test-login/ with heading \"Test login\" and both fields empty"]}]},{"id":"NAV","title":"Navigation","summary":"Practice index and primary menu.","reqs":[{"id":"FR-NAV-01","weight":"Should","text":"The Practice page lists the practice pages and each link opens its page.","accept":["Home has a \"Practice\" button leading to heading \"Practice\" with links \"Test Login Page\" and \"Test Table\"","Test Login Page opens \"Test login\"; Test Table opens \"Test Table\""]},{"id":"FR-NAV-02","weight":"Could","text":"Every primary menu item resolves; placeholder pages say they are coming soon and the brand returns home.","accept":["Courses, Blog and Contact show headings \"Courses\", \"Blog\", \"Contact\", each with \"Coming soon.\"","Clicking \"Proving Ground\" opens Home with heading \"Hello\"","Menu: Home, Practice, Courses, Blog, Contact"]}]},{"id":"TBL","title":"Courses table","summary":"Static table for reading and assertion exercises.","reqs":[{"id":"FR-TBL-01","weight":"Must","text":"The table #courses_table lists exactly five courses with fixed columns and content.","accept":["Columns: ID, Course Name, Language, Level, Enrollments","1 WebDriver with Java for beginners, Java, Beginner, 28,000+","2 WebDriver with Java: Advanced, Java, Advanced, 12,000+","3 Browser automation with TypeScript, TypeScript, Intermediate, 9,000+","4 End-to-end testing fundamentals, JavaScript, Beginner, 15,000+","5 Python browser automation, Python, Intermediate, 18,000+","Most enrolments: course 1"]},{"id":"FR-TBL-02","weight":"Should","text":"Filtering by reading yields a predictable result for Level.","accept":["Level \"Beginner\": exactly \"WebDriver with Java for beginners\" (Java) and \"End-to-end testing fundamentals\" (JavaScript)"]}]}],"nfr":[],"glossary":[{"term":"Practice credentials","meaning":"The username and password shown on the login page for learners to use."}],"outOfScope":["Server-side authentication","Blog and course content"]},{"ns":"fontprobe","title":"Cross-site Font Probe","version":"1.0","summary":"Cross-site Font Probe is an in-browser diagnostic that measures, on the visitor's own device, how text and flag emoji render: it detects the operating system and browser, counts truncated characters, checks which fonts are installed, resolves CSS font stacks and measures flag glyph widths and overflow. Results are shown on three tabs.","problem":"Teams shipping multilingual interfaces need to know whether fonts and emoji render and fit the same way across operating systems and browsers, using real measurements rather than assumptions.","personas":[{"name":"Front-end developer","need":"See which fonts exist and where text is cut off on a given device."},{"name":"QA engineer","need":"Compare measurements across platforms and trust that every table is internally consistent."}],"goals":["Measurements run in the visitor's real browser and finish quickly.","Every table is internally consistent on any platform: counts add up and statuses match their numbers."],"features":[{"id":"STS","title":"Status and tabs","summary":"Measurement status, environment detection and the three-tab layout.","reqs":[{"id":"FR-STS-01","weight":"Must","text":"The probe finishes measuring and reports the real operating system and browser.","accept":["Header \"Font probe\"; status starts as \"Đang đo…\"","Within 5 s the status reads \"Đo xong · <OS> · <Browser>\" in green, matching the actual platform (for example macOS · Chrome)"]},{"id":"FR-STS-02","weight":"Should","text":"Tabs \"1 · Truncation\", \"2 · Fonts\" and \"3 · Flags\" follow the ARIA tab pattern: exactly one panel is visible and aria-selected tracks the active tab.","accept":["Tab 1 is selected on load with panels 2 and 3 hidden","Selecting tab 2 shows panel \"2A · Font availability\" only; tab 3 shows only panel 3"]},{"id":"FR-STS-03","weight":"Could","text":"The legacy path /fontprobe/font-probe.html serves the same probe."}]},{"id":"TRN","title":"Truncation measurements","summary":"Two 420 px single-line boxes holding the same Vietnamese sample.","reqs":[{"id":"FR-TRN-01","weight":"Must","text":"Both boxes (1A, 1B) hold the same 474-code-point text and report Visible, Cut and Total so that Visible + Cut = Total.","accept":["Total is 474 in both rows","Visible + Cut = 474 in each row; Visible > 0 and Cut > 0"]},{"id":"FR-TRN-02","weight":"Should","text":"Box 1B uses Arial with text-overflow: ellipsis and ends with an ellipsis; box 1A is hard-clipped with no ellipsis.","accept":["Both are 420 px wide, single line, overflow hidden"]},{"id":"FR-TRN-03","weight":"Could","text":"Box 1A begins \"Tiếng Việt có dấu: Người dùng đăng nhập…\" and Visible values are integers that may differ between 1A and 1B because the font families differ."}]},{"id":"FNT","title":"Font availability and stacks","summary":"Which fonts are installed and how CSS stacks resolve.","reqs":[{"id":"FR-FNT-01","weight":"Must","text":"Five fonts are probed (Segoe UI, Segoe UI Emoji, Liberation Sans, Noto Color Emoji, Arial) and each row's status matches its measured delta.","accept":["MISSING rows show \"Δ 0.00\" in red; AVAILABLE rows show a non-zero Δ in green","On macOS Segoe UI and Segoe UI Emoji are MISSING and Arial is AVAILABLE; on Windows Segoe UI is AVAILABLE"]},{"id":"FR-FNT-02","weight":"Should","text":"Table 2B resolves the stacks system-ui, sans-serif, \"Segoe UI\" and Arial, each with its computed family and an advance width.","accept":["Advance width values end in \" px\", have two decimals and are greater than 0"]}]},{"id":"FLG","title":"Flag measurements","summary":"Country-code rows, overflow checks and flag glyph widths.","reqs":[{"id":"FR-FLG-01","weight":"Should","text":"Seven 150 px country-code rows are listed and measured; each status follows scrollWidth against clientWidth.","accept":["3A rows: Việt Nam +84, United States +1, Japan +81, South Korea +82, Singapore +65, Germany +49, Brazil +55, each with its flag","3B has 7 rows; Status is \"truncated\" exactly when scrollWidth > clientWidth, otherwise \"ok\"","At least United States and South Korea are truncated at 150 px"]},{"id":"FR-FLG-02","weight":"Should","text":"Table 3C compares the Vietnam flag glyph with the letters \"VN\" and its arithmetic is exact.","accept":["Reads width(flag) = X px · width(\"VN\") = Y px · Δ = Z px with Z = X - Y within 0.01"]},{"id":"FR-FLG-03","weight":"Could","text":"The 56 px readonly \"Country code\" input (value Vietnam flag + \"+84\") cannot be edited and reports its own overflow.","accept":["Status \"scrollWidth A / clientWidth B → ok\" or \"truncated\", consistent with A <= B meaning ok","Typing does not change the value"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"Every measurement table is internally consistent on any OS and browser: relationships between numbers hold even though absolute pixel values legitimately differ per platform."}],"outOfScope":["Absolute pixel values (they differ per platform)"]},{"ns":"flow","title":"Flow benchmark fixture","version":"1.0","summary":"Flow benchmark is a one-page fixture: a heading, a status line and a single Name field with a Save button. Saving shows the saved name in the status line; a small API mirrors the page.","problem":"Testers and automation runners need a minimal, fully predictable page against which every message and rule can be checked exactly.","personas":[{"name":"Tester","need":"Verify page text, validation and keyboard behaviour against exact wording."},{"name":"Automation runner","need":"Read the page faithfully and report disagreement between an expectation and what is shown."}],"goals":["The page shows exactly the wording specified here.","Validation rules are identical on the page and in the API."],"features":[{"id":"PAG","title":"Page content","summary":"Heading, status line and form shown on load.","reqs":[{"id":"FR-PAG-01","weight":"Must","text":"The page at /flow/ shows the heading \"Flow benchmark\", a status line reading \"Ready\" and a form with one text field labelled \"Name\" and a \"Save\" button.","accept":["The status line always starts as \"Ready\"; it never reads \"Offline\" or any other wording on load","The Name field is empty on load"]},{"id":"FR-PAG-02","weight":"Should","text":"The status is not persisted: reloading the page resets it to \"Ready\" and empties the Name field."}]},{"id":"SAV","title":"Save and validation","summary":"Saving a name and rejecting empty input.","reqs":[{"id":"FR-SAV-01","weight":"Must","text":"Saving a name replaces the status with \"Saved: <name>\".","accept":["\"Ada\" shows \"Saved: Ada\"; the wording is \"Saved\", not \"Stored\""]},{"id":"FR-SAV-02","weight":"Must","text":"An empty name is rejected with \"Name is required\" in the status line, before any request is made."},{"id":"FR-SAV-03","weight":"Should","text":"The name is trimmed before validation and saving.","accept":["Only spaces: \"Name is required\"","\"  Grace  \" is saved as \"Saved: Grace\""]},{"id":"FR-SAV-04","weight":"Should","text":"Each successful save replaces the previous one; only the latest name is shown.","accept":["Saving \"Ada\" then \"Alan\" shows \"Saved: Alan\""]},{"id":"FR-SAV-05","weight":"Should","text":"Pressing Enter in the Name field submits the form exactly like the Save button."}]},{"id":"API","title":"Status and save API","summary":"HTTP endpoints that mirror the page.","reqs":[{"id":"FR-API-01","weight":"Should","text":"POST /api/flow/save with a name returns 200 {\"status\":\"Saved: <name>\"}; GET /api/flow/status then returns status \"Ready\" and lastSaved set to the name."},{"id":"FR-API-02","weight":"Should","text":"POST /api/flow/save with an empty or missing name returns 422 with errors.name \"Name is required\"."}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"The status line is a polite live region (role \"status\", aria-live \"polite\") and the Name field is associated with its label."}],"outOfScope":["Persistence across browsers","Styling"]},{"ns":"vault","title":"Vault Ledger","version":"2.6","summary":"Vault Ledger is a retail-banking view for internal transfers. A customer sees a Portfolio of a Checking and a Savings account with net liquidity, moves money between them through Move Funds, Review Transfer and an explicit Authorize step, and gets a reference for every completed transfer.","problem":"Customers must be certain that an authorized transfer debits and credits exactly the amount shown at review, so that balances and net liquidity always reconcile to the cent.","personas":[{"name":"Retail customer","need":"Shift money between own accounts and see exact balances afterwards."}],"goals":["Every transfer debits the origin and credits the destination by exactly the reviewed amount; net liquidity never changes.","Nothing moves until the customer authorizes it.","Invalid amounts and account choices are refused at review with a specific message."],"features":[{"id":"PRT","title":"Portfolio","summary":"Balances and net liquidity.","reqs":[{"id":"FR-PRT-01","weight":"Must","text":"A fresh session shows the seeded balances and net liquidity equal to their sum.","accept":["Heading \"Portfolio\"; Checking •••• 4417 $4,823.50; Savings •••• 9032 $12,150.00; Net liquidity $16,973.50"]},{"id":"FR-PRT-02","weight":"Should","text":"Each browser tab has its own ledger and balances are stored on the server, so they survive a reload but never leak into another tab.","accept":["After a $100.00 transfer in one tab Checking is $4,723.50; a second new tab shows $4,823.50","Balances are identical after a reload"]}]},{"id":"TRF","title":"Transfer flow","summary":"Move Funds, Review Transfer and Authorize.","reqs":[{"id":"FR-TRF-01","weight":"Must","text":"Review shows the origin, destination and amount; Authorize Transfer then moves money and shows a confirmation with a reference starting \"TRF-\". Returning to the Portfolio shows the new balances.","accept":["Checking → Savings $50.00: \"Review transfer\" From Checking, To Savings, Amount $50.00; \"Transfer authorized\" — \"Moved $50.00 from Checking to Savings.\"","Checking $4,773.50; Savings $12,200.00; Net liquidity $16,973.50"]},{"id":"FR-TRF-02","weight":"Must","text":"Transfers work in both directions and keep net liquidity unchanged.","accept":["Savings → Checking $1,250.75: \"Moved $1,250.75 from Savings to Checking.\"; Checking $6,074.25; Savings $10,899.25; Net liquidity $16,973.50"]},{"id":"FR-TRF-03","weight":"Should","text":"Balances carry over between transfers in the same session.","accept":["$20.00 then $30.00 from Checking: Checking $4,773.50, Savings $12,200.00, same after a reload"]},{"id":"FR-TRF-04","weight":"Could","text":"The From list labels each account with its current balance; the To list shows names only, with Savings preselected.","accept":["From: \"Checking ($4,823.50)\", \"Savings ($12,150.00)\"; To: \"Checking\", \"Savings\""]},{"id":"FR-TRF-05","weight":"Should","text":"Edit on the review screen returns to Move Funds with the previous values; Cancel at any step leaves balances unchanged.","accept":["Edit shows Amount \"75.00\", From Checking, To Savings","Cancel returns to the Portfolio with Checking $4,823.50"]},{"id":"FR-TRF-06","weight":"Could","text":"Reloading in the middle of a transfer returns to the Portfolio and nothing is transferred.","accept":["After review of $60.00 and a reload: Portfolio, Checking $4,823.50"]}]},{"id":"AMT","title":"Amount validation","summary":"Limits, rounding and account choice.","reqs":[{"id":"FR-AMT-01","weight":"Must","text":"A transfer may use the entire available balance but not one cent more.","accept":["4823.50 from Checking: Checking $0.00, Savings $16,973.50","4823.51: alert \"Amount exceeds the available balance.\"; stays on Move Funds"]},{"id":"FR-AMT-02","weight":"Must","text":"Only positive amounts are accepted; zero, negative, blank and non-numeric values are refused.","accept":["\"0\", \"-5\", empty and \"fifty\": \"Enter an amount greater than $0.00.\""]},{"id":"FR-AMT-03","weight":"Should","text":"Amounts are rounded to whole cents; the smallest valid transfer is $0.01.","accept":["\"0.004\" rounds to $0.00 and is refused with \"Enter an amount greater than $0.00.\"","\"0.01\": Checking $4,823.49; Savings $12,150.01"]},{"id":"FR-AMT-04","weight":"Must","text":"Origin and destination must differ.","accept":["\"Choose two different accounts.\""]},{"id":"FR-AMT-05","weight":"Should","text":"An amount typed with a thousands separator is accepted and shown correctly on the review screen.","accept":["\"1,000\" reviews as Amount $1,000.00"]}]},{"id":"API","title":"Ledger API","summary":"REST access to the portfolio, review and transfers.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The portfolio is returned in cents per session; review validates without moving money.","accept":["checking 482350, savings 1215000, netLiquidity 1697350","Review checking → savings \"50\": 200, amount 5000, fromName \"Checking\", toName \"Savings\"; balances unchanged","Same account both sides: 422 \"Choose two different accounts.\""]},{"id":"FR-API-02","weight":"Should","text":"Authorize returns the new balances and the session’s transfers are listed; over-limit amounts are refused.","accept":["Authorize $50: 201, id \"TRF-…\", status \"authorized\", checking 477350, savings 1220000, netLiquidity 1697350","List shows exactly one transfer of amount 5000","Amount \"999999\": 422 \"Amount exceeds the available balance.\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Ledger integrity: for every authorized transfer the origin falls and the destination rises by exactly the reviewed amount, so net liquidity is unchanged."},{"id":"NFR-02","weight":"Should","text":"Data format: money is shown with a $ sign, thousands separators and two decimals; the API uses integer cents."}],"glossary":[{"term":"Net liquidity","meaning":"Checking balance plus Savings balance."},{"term":"Review Transfer","meaning":"The validation step that precedes authorization and moves no money."}],"outOfScope":["External transfers and payees","Authentication"]},{"ns":"kestrel","title":"Kestrel Banking","version":"4.2","summary":"Kestrel Banking is a private-banking vault. Clients sign in with a Secure ID, see their accounts and cards with net liquidity, move money between their deposit accounts behind an explicit authorization step, and open new products through a three-step application with a calendar date picker.","problem":"Clients must be certain that every transfer or product funding is authorized deliberately and that the money is always accounted for: balances and net liquidity must reconcile after every operation.","personas":[{"name":"Private-banking client","need":"See total liquidity at a glance, move funds safely and open new products without paperwork."},{"name":"Applicant","need":"Complete identity, residence and funding steps with clear field-level feedback."}],"goals":["No money is created or lost: every debit lands somewhere and net liquidity reconciles.","Nothing moves until the client explicitly authorizes it.","Every field error is specific and appears before an application advances."],"features":[{"id":"AUT","title":"Vault sign-in","summary":"Secure ID login, session gating and sign-out.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"A valid Secure ID and Access Code open the Portfolio; the header shows the client’s handle, the Portfolio and Move Funds navigation and Sign Out.","accept":["URL /kestrel/dashboard, heading \"Portfolio\", \"Welcome back, @<secure ID>\""]},{"id":"FR-AUT-02","weight":"Must","text":"Unrecognised credentials are refused with one generic message and the login card stays.","accept":["\"Access denied — secure ID or access code not recognised\""]},{"id":"FR-AUT-03","weight":"Should","text":"\"Enter Vault Access\" is disabled until both fields contain input; spaces alone do not count as a Secure ID."},{"id":"FR-AUT-04","weight":"Could","text":"Show / Hide toggles whether the Access Code is displayed in clear text; the button label alternates between \"Show\" and \"Hide\"."},{"id":"FR-AUT-05","weight":"Must","text":"Every vault page requires a session: signed-out visits go to /kestrel/login, and after Sign Out the Back button does not reveal portfolio data."}]},{"id":"PRT","title":"Portfolio","summary":"Facilities, net liquidity and sidebar.","reqs":[{"id":"FR-PRT-01","weight":"Must","text":"Accounts & Cards lists every facility with its type, mask and balance (cards show \"Balance owed\"); Net Liquidity equals the sum of deposit balances minus card balances owed.","accept":["Checking •••• 2841 $12,480.55; Savings •••• 5530 $48,210.00; Vela Signature •••• 7710 \"Balance owed $642.12\"","Net Liquidity $60,048.43 (12,480.55 + 48,210.00 − 642.12)"]},{"id":"FR-PRT-02","weight":"Could","text":"The sidebar offers three products and two vault alerts.","accept":["Savings \"4.10% APY · no lockup\", Checking \"Zero-fee daily liquidity\", Platinum Credit Card \"3× points · concierge\"","Alerts \"Statement for September is ready.\" and \"New device sign-in verified.\""]}]},{"id":"TRF","title":"Move funds","summary":"Internal transfers with a Review Protocol and authorization dialog.","reqs":[{"id":"FR-TRF-01","weight":"Must","text":"A transfer is reviewed first and only moves money when the client clicks \"Authorize Transfer\" in the \"Authorization Protocol\" dialog, which restates origin, recipient and amount.","accept":["Checking → Savings $500.00 shows Origin Checking •••• 2841, Recipient Savings •••• 5530, Amount $500.00","After authorizing: Checking $11,980.55, Savings $48,710.00, Net Liquidity unchanged at $60,048.43","Savings → Checking $10,000 gives Checking $22,480.55, Savings $38,210.00"]},{"id":"FR-TRF-02","weight":"Should","text":"Only deposit accounts are transfer facilities: Origin and Recipient list Checking •••• 2841 and Savings •••• 5530, never the card. The form shows the origin’s \"Available\" balance, which follows the selected origin.","accept":["Origin Checking: \"Available: $12,480.55\"; Origin Savings: \"Available: $48,210.00\""]},{"id":"FR-TRF-03","weight":"Must","text":"Origin and recipient must differ.","accept":["\"Origin and recipient facilities must differ\"; no dialog"]},{"id":"FR-TRF-04","weight":"Must","text":"The amount must be a number above zero.","accept":["Empty, \"0\" or \"ten\": \"Enter a liquid capital amount above $0.00\""]},{"id":"FR-TRF-05","weight":"Must","text":"A transfer may use the full available balance but not a cent more.","accept":["12480.56 from Checking: \"Insufficient liquidity in origin facility\"","12480.55 succeeds: Checking $0.00, Savings $60,690.55"]},{"id":"FR-TRF-06","weight":"Should","text":"Abort or Escape closes the authorization dialog without moving money and the form keeps its values.","accept":["After Abort the form still shows $250 and Checking stays $12,480.55"]},{"id":"FR-TRF-07","weight":"Could","text":"Editing the amount hides the previous validation error."}]},{"id":"APP","title":"Product application","summary":"Three-step application: identity, residence, initial allocation.","reqs":[{"id":"FR-APP-01","weight":"Must","text":"The application has three steps — Identity Verification, Residence Information, Initial Asset Allocation — and a successful application provisions a new facility with a confirmation.","accept":["Heading \"Apply for Savings\"; date hint \"March 22, 1985\"","Externally funded $100: \"Facility Approved\" — \"Savings was successfully provisioned.\" and \"Savings •••• 1148 · Opening balance $100.00\"","Dashboard shows four facilities and Net Liquidity $60,148.43"]},{"id":"FR-APP-02","weight":"Should","text":"A facility funded from an existing account is debited that account by the funding amount and opens with the same balance; net liquidity is unchanged.","accept":["Checking funded $1,000 from Checking •••• 2841: new \"Checking •••• 1148 · Opening balance $1,000.00\"; source $11,480.55; Net Liquidity $60,048.43"]},{"id":"FR-APP-03","weight":"Should","text":"An internal funding source must cover the amount.","accept":["$20,000 from Checking: \"Insufficient liquidity in origin facility\" under the amount"]},{"id":"FR-APP-04","weight":"Should","text":"Funding source and amount are both required.","accept":["\"Select a funding source\" and \"Funding amount required\""]},{"id":"FR-APP-05","weight":"Must","text":"Funding a new credit card from an account keeps the money accounted for: the amount funded becomes the card’s balance so Net Liquidity is unchanged.","accept":["Platinum Credit Card funded $100 from Checking: Checking $12,380.55 and Net Liquidity $60,048.43"]},{"id":"FR-APP-06","weight":"Should","text":"Identity Continue is disabled until first name, last name and a valid date of birth are present; the hint reads \"Format: YYYY-MM-DD\" until a valid date is typed, then the long date.","accept":["\"1985-03-22\" enables Continue with hint \"March 22, 1985\""]},{"id":"FR-APP-07","weight":"Should","text":"Date of birth must be a real calendar date from 1900-01-01 up to today, typed as YYYY-MM-DD.","accept":["\"2023-02-29\", \"03/22/1985\", \"1899-12-31\" and any future date disable Continue","\"2024-02-29\" is accepted: \"February 29, 2024\""]},{"id":"FR-APP-08","weight":"Should","text":"The calendar popup (\"Open calendar\") fills the field in ISO format and can navigate by month and year.","accept":["Popup \"Choose date of birth\" opens on January 1990","July 1972, day 4: field \"1972-07-04\", hint \"July 4, 1972\"; reopening shows July 1972 with day 4 selected; \"Previous month\" twice reaches May 1972"]},{"id":"FR-APP-09","weight":"Should","text":"Residence fields are required and each shows its own error.","accept":["\"Primary residence required\", \"City required\", \"State required\", \"Valid 5-digit ZIP code required\"","ZIP \"9410\" keeps the ZIP error"]},{"id":"FR-APP-10","weight":"Must","text":"A ZIP code is exactly five digits and is treated as text: leading zeros are valid and anything else is refused.","accept":["02101 (Massachusetts) is accepted and \"Initial Asset Allocation\" opens","\"1e4\" shows \"Valid 5-digit ZIP code required\""]},{"id":"FR-APP-11","weight":"Could","text":"Back keeps every value typed in earlier steps.","accept":["Residence \"456 Oak Ave\", \"Springfield\", California, 94105; Identity Jane / Smith / 1985-03-22"]},{"id":"FR-APP-12","weight":"Could","text":"An unknown product URL shows \"Unknown product\" with a \"Back to Portfolio\" button.","accept":["/kestrel/apply/Gold%20Bullion: \"We couldn't find “Gold Bullion”.\""]}]},{"id":"API","title":"Vault API","summary":"REST access to facilities, transfers and applications.","reqs":[{"id":"FR-API-01","weight":"Should","text":"Facilities are served only with a bearer token; amounts are in cents.","accept":["No token: 401","With token: 200, three facilities, netLiquidity 6004843"]},{"id":"FR-API-02","weight":"Should","text":"Transfer endpoints enforce the same rules as the form.","accept":["Review with a card as recipient: 422 \"Select origin and recipient facilities\"","Valid $50 transfer: 201, id \"KTX-…\", status \"authorized\", Checking balance 1243055"]},{"id":"FR-API-03","weight":"Could","text":"Applications reject unknown products and report every field error.","accept":["Unknown product: 404 \"Unknown product Gold\"","Empty sections: 422 with errors for firstName, lastName, dob, address, city, state, zip, source and amount"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Security: vault pages and API facilities are never available without authentication, and a generic message is shown for bad credentials."},{"id":"NFR-02","weight":"Should","text":"Accessibility: dialogs close with Escape."},{"id":"NFR-03","weight":"Should","text":"Data format: money is displayed as $ with thousands separators and two decimals; dates are entered and stored as YYYY-MM-DD."}],"glossary":[{"term":"Net Liquidity","meaning":"Deposit balances minus the balance owed on cards."},{"term":"Facility","meaning":"Any account or card in the portfolio."},{"term":"Review Protocol","meaning":"The review step before the Authorization Protocol dialog."}],"outOfScope":["External wires (simulated as new funds)","Statement and alert content"]},{"ns":"outfitter","title":"Outfitter","version":"1.0","summary":"Outfitter is a practice online storefront: shoppers sign in, browse a six-item catalogue, sort it, open product pages, build a cart and complete a three-step checkout (information, overview, confirmation) with 8% sales tax.","problem":"Shoppers need to find items, trust the prices they see and complete an order without surprises; the order total must always equal item total plus tax.","personas":[{"name":"Shopper","need":"Sign in, sort and browse items, add and remove them, and place an order with a clear price breakdown."},{"name":"Locked-out shopper","need":"Be told clearly that the account cannot sign in."},{"name":"Integrator","need":"Read the catalogue, validate a login and place orders through a small API."}],"goals":["Every account type gets the same correct catalogue, sorting, cart and checkout behaviour; only sign-in speed and lockout differ.","The price a shopper sees is the list price on every page.","Tax is exactly 8% of the item total, rounded to the cent, and the order total reconciles."],"features":[{"id":"SGN","title":"Sign-in","summary":"Login form with per-field errors and account states.","reqs":[{"id":"FR-SGN-01","weight":"Must","text":"A valid account signs in and lands on the Products page showing six products.","accept":["Page title \"Products\" and URL ending /inventory.html","Each of the six cards shows name, description, price and \"Add to cart\""]},{"id":"FR-SGN-02","weight":"Must","text":"Missing fields are reported one at a time, username first, then password.","accept":["Both empty: \"Oh snap: Username is required\"","Username only: \"Oh snap: Password is required\"","The ✕ on the error banner dismisses it"]},{"id":"FR-SGN-03","weight":"Must","text":"Unknown users, wrong passwords and differently-cased usernames are rejected with one generic message and the shopper stays on the login page.","accept":["\"Oh snap: Username and password do not match any user in this service\"","Usernames are case-sensitive"]},{"id":"FR-SGN-04","weight":"Must","text":"A locked-out account with the right password cannot sign in.","accept":["\"Oh snap: Sorry, this user has been locked out.\" and the login page remains"]},{"id":"FR-SGN-05","weight":"Should","text":"The performance-test account signs in only after about 3 seconds; the Login button shows \"Logging in…\" and is disabled meanwhile.","accept":["This delay is accepted, contracted behaviour for that account","After the delay the Products page opens"]}]},{"id":"SES","title":"Session and gating","summary":"Protected pages and logout.","reqs":[{"id":"FR-SES-01","weight":"Should","text":"Protected pages opened without signing in show the login form with an explanation naming the page.","accept":["/cart.html: \"Oh snap: You can only access '/cart.html' when you are logged in.\"","The same for /inventory.html"]},{"id":"FR-SES-02","weight":"Must","text":"Logout returns to the login page, empties the cart and re-gates protected pages.","accept":["After logging out and signing in again there is no cart badge","Opening /inventory.html after logout shows the gating message"]}]},{"id":"CAT","title":"Catalogue and product detail","summary":"Sortable product list and product pages, identical for every account.","reqs":[{"id":"FR-CAT-01","weight":"Must","text":"The catalogue opens sorted Name (A to Z) and can be re-sorted; the list always matches the chosen sort.","accept":["Name (A to Z): Backpack, Bike Light, Bolt T-Shirt, Fleece Jacket, Onesie, Test Everything T-Shirt (Red)","Name (Z to A) is the exact reverse","Price (low to high): $7.99, $9.99, $15.99, $15.99, $29.99, $49.99 — Bolt T-Shirt before the Red T-Shirt (equal prices keep name order)","Price (high to low): $49.99, $29.99, $15.99, $15.99, $9.99, $7.99"]},{"id":"FR-CAT-02","weight":"Must","text":"Every product shows its list price on the Products page, matching the cart and checkout.","accept":["Backpack $29.99, Bike Light $9.99, Bolt T-Shirt $15.99, Fleece Jacket $49.99, Onesie $7.99, Test Everything T-Shirt (Red) $15.99"]},{"id":"FR-CAT-03","weight":"Should","text":"Each product shows its own product image, and every account sees the same product art.","accept":["The six images are distinct from one another"]},{"id":"FR-CAT-04","weight":"Should","text":"Clicking a product name opens its detail page with name, description, price and an Add to cart / Remove toggle that shares the cart with the list.","accept":["The Fleece Jacket opens /inventory-item.html?id=5 showing $49.99","\"← Back to products\" returns to the list","Adding on the detail page shows \"Remove\" there and in the list, and the badge becomes 1"]},{"id":"FR-CAT-05","weight":"Could","text":"An unknown product id shows a not-found page.","accept":["\"ITEM NOT FOUND\" and \"We're sorry, but your call could not be completed as dialled.\""]}]},{"id":"CRT","title":"Cart","summary":"Add, remove, persist and reset items.","reqs":[{"id":"FR-CRT-01","weight":"Must","text":"Every product’s Add to cart button adds that product, flips to \"Remove\", and the cart badge always equals the number of items in the cart.","accept":["Adding all six products gives badge 6","Removing one item lowers the badge by 1","Adding any single product, including the Bolt T-Shirt, shows \"Remove\" and badge 1"]},{"id":"FR-CRT-02","weight":"Should","text":"The cart page lists each item with quantity 1 and its list price and allows removal.","accept":["Title \"Your Cart\"; Backpack $29.99 and Fleece Jacket $49.99 each QTY 1","Continue Shopping returns to Products with the items still in the cart"]},{"id":"FR-CRT-03","weight":"Could","text":"The cart persists across page reloads while signed in.","accept":["After a reload the badge and \"Remove\" buttons are unchanged"]},{"id":"FR-CRT-04","weight":"Could","text":"Menu \"Reset App State\" empties the cart without signing out; About and All Items navigate and the menu closes.","accept":["After reset, reload shows \"Add to cart\" on every product","About shows title \"About\"; All Items shows Products"]}]},{"id":"CHK","title":"Checkout","summary":"Three steps: information, overview, complete.","reqs":[{"id":"FR-CHK-01","weight":"Must","text":"Your Information requires First Name, Last Name and Postal Code, checked in that order, and accepts a typed value in every field.","accept":["\"Error: First Name is required\", then \"Error: Last Name is required\", then \"Error: Postal Code is required\"","Text typed into each field stays in that field only","With all three filled, Continue opens \"Checkout: Overview\""]},{"id":"FR-CHK-02","weight":"Must","text":"The overview shows item total, tax of 8% rounded to the cent, and the total; Finish places the order.","accept":["Backpack: Item total $29.99, Tax $2.40, Total $32.39","Backpack + Bike Light: $39.98, $3.20, $43.18","All six items: $129.94, $10.40, $140.34","The overview shows \"TrailCard #31337\" and \"Free Pony Express Delivery!\""]},{"id":"FR-CHK-03","weight":"Must","text":"Finish completes the order for every account: \"Checkout: Complete!\" with \"Thank you for your order!\", the cart is emptied and Back Home returns to Products with every item showing \"Add to cart\"."},{"id":"FR-CHK-04","weight":"Should","text":"An empty cart can reach the overview (all amounts $0.00) but the order is refused.","accept":["Finish shows the inline error \"Cart is empty\" and stays on the overview"]},{"id":"FR-CHK-05","weight":"Could","text":"Cancel on Your Information returns to the cart; Cancel on the overview returns to Products; the cart is kept in both cases."}]},{"id":"API","title":"API","summary":"Catalogue, login and order endpoints.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The catalogue endpoint returns the six products and a single-product lookup; unknown ids return 404.","accept":["The Backpack has id 4 and price 29.99","404 \"Product not found\""]},{"id":"FR-API-02","weight":"Should","text":"Login maps each outcome to a status and message.","accept":["Empty: 400 \"Oh snap: Username is required\"","Wrong password: 401 \"…do not match any user in this service\"","Locked account: 403 \"Oh snap: Sorry, this user has been locked out.\"","A valid account returns 200"]},{"id":"FR-API-03","weight":"Should","text":"Orders validate shipping fields and compute totals on the server.","accept":["Items [4, 0] for Ada Lovelace 10115: 201 with item 39.98, tax 3.2, total 43.18","Empty last name: 422 \"Error: Last Name is required\"","Only unknown ids: 422 \"Cart is empty\" (unknown ids are dropped)"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Money is shown with a $ sign and exactly two decimals, and tax is calculated from the item total (8%, rounded half up to the cent) identically in the UI and the API."}],"glossary":[{"term":"Item total","meaning":"Sum of list prices of the items in the cart."},{"term":"Tax","meaning":"8% of the item total, rounded to the cent."}],"outOfScope":["Real payment and shipping","Footer social links"]},{"ns":"hub","title":"Scenario Hub","version":"2.0","summary":"Scenario Hub is a collection of 17 small, self-contained web apps, each modelling one everyday task: portal sign-in, an AI chat workspace, contact editing, record deletion, notification templates (plain and with single sign-on), a help centre, a 100-field intake packet, a marketing site with demo booking, clinic booking, a policy register, an order handoff, an 800-row report queue, stock locations, a German travel-agent portal with 2FA, a drag-and-drop work queue and a Turkish quiz login. A scenario index links them all and a small API under /api/hub backs them.","problem":"Users complete small business tasks in each app and must be able to trust that what the screen reports is what actually happened: errors are shown, the right record is changed, and saved data is what was entered.","personas":[{"name":"Portal user","need":"Sign in, get clear feedback on failure, and reach their own data."},{"name":"Operations clerk","need":"Find the one correct record among look-alikes and change only that one."},{"name":"Customer or patient","need":"Book a demo or appointment and see the booking reflected back exactly."}],"goals":["Every rejected action tells the user why, in the documented words.","Actions affect only the record the user chose, never a look-alike.","State is isolated per browser tab and every scenario can be reset."],"features":[{"id":"IDX","title":"Scenario index","summary":"Landing page and API listing of all scenarios.","reqs":[{"id":"FR-IDX-01","weight":"Must","text":"The hub lists all 17 scenarios as cards with a title and blurb and each card opens its app.","accept":["Heading \"Scenario Hub\"; 17 cards","\"Policy register\" opens /hub/policy-register/ with document title \"Policy register\"","GET /api/hub/scenarios returns 17 entries with slug, title and blurb; the first is ai-assistant / \"Helix Workspace\""]}]},{"id":"SLG","title":"Portal sign-in","summary":"Email and password sign-in to a demo portal (Bluepine).","reqs":[{"id":"FR-SLG-01","weight":"Must","text":"Valid demo credentials open the dashboard and Sign out returns to an empty form.","accept":["Button shows \"Signing in…\", then \"Welcome back, Demo User\" and \"Your Bluepine dashboard is ready.\""]},{"id":"FR-SLG-02","weight":"Must","text":"A rejected sign-in tells the user why and clears the password.","accept":["An element with role=alert reads \"The email or password is incorrect.\"","The user stays on the form with the password cleared; a following valid sign-in succeeds"]}]},{"id":"AIA","title":"AI assistant workspace","summary":"Chat workspace (Helix) with files, saved prompts and a model selector.","reqs":[{"id":"FR-AIA-01","weight":"Should","text":"The workspace shows its tabs, seeded chats and composer defaults.","accept":["Tabs Chat, Files, Prompts, Helix Pro, Settings","\"+ New chat\" and \"Chats (3)\": Release checklist review (2 messages), Onboarding FAQ draft (4), Q3 roadmap notes (2)","Textarea \"Message\" with placeholder \"Message Helix…\"; model \"AI: Helix Lite\"; Send disabled while empty","Prompts: \"No saved prompts yet.\"; Helix Pro shows an \"Upgrade workspace\" upsell"]},{"id":"FR-AIA-02","weight":"Must","text":"Asking to summarise an attached file that has several audiences asks a clarifying question before answering.","accept":["Attach release-notes-v4.2.md (shown as \"Attached: release-notes-v4.2.md\" with a remove control), send \"Summarize this\": \"Thinking…\", then a streamed reply listing 3 changes and asking \"Should this summary target the customer changelog or the internal team?\"","Replying \"customer changelog\" gives a reply starting \"Helix 4.2 — Customer changelog\"","The sidebar then shows \"Chats (4)\" with the new chat on top"]},{"id":"FR-AIA-03","weight":"Should","text":"Answers depend on the attachment and the question as documented.","accept":["release-notes-v4.1.md + \"summarize\": \"2 changes: Bulk user import, Dark mode\"","No attachment + \"summarize\": \"Attach the document you want summarised and I will take a look.\"","\"Any release blockers?\": \"Two release blockers are still open: SEC-88 (SSO token refresh fails in Safari) and DOC-12 (admin guide lacks the audit-log export section).\"","Unrelated question (\"What is the weather?\"): \"I couldn't find anything about that in this workspace.\""]},{"id":"FR-AIA-04","weight":"Should","text":"POST /api/hub/ai-assistant/messages validates its input.","accept":["Empty text: 422 \"Message is empty\"","Model \"Helix Ultra\": 422 \"Unknown model Helix Ultra\"","Attachment \"secret.txt\": 422 \"Unknown file secret.txt\"","chatId \"nope\": 404 \"Chat not found\"","{text:\"what are the blockers?\", model:\"Helix Lite\"}: 200 with a new chat titled with the text whose assistant message names SEC-88 and DOC-12"]}]},{"id":"CTC","title":"Billing contacts","summary":"Edit a billing contact with revision tracking.","reqs":[{"id":"FR-CTC-01","weight":"Should","text":"Saving a contact without changes reports there was nothing to change and keeps the revision.","accept":["Avery Stone (BC-11) opens at /contacts/BC-11 as \"Edit billing contact\", \"Avery Stone · Revision 7\", email avery.stone@example.test, company Bluepine Labs, phone +1 555 0142","Save without edits: \"No changes — revision 7\""]},{"id":"FR-CTC-02","weight":"Should","text":"A real change bumps the revision by one and shows in the list.","accept":["Phone \"+1 555 0199\" saved: \"Saved — revision 8\" and \"Avery Stone · Revision 8\"","The contacts list shows Rev. 8"]},{"id":"FR-CTC-03","weight":"Could","text":"Invalid fields are rejected per field and nothing is saved.","accept":["\"Name is required\", \"Enter a valid email address\", \"Company is required\", \"Enter a valid phone number\"","After Cancel the contact is still revision 7 with its original values"]}]},{"id":"DEL","title":"Customer records deletion","summary":"Delete one of several look-alike records after confirmation.","reqs":[{"id":"FR-DEL-01","weight":"Should","text":"Deleting a record removes only the one chosen after a confirmation that identifies it.","accept":["Initial \"4 records\": CR-4101 Active 18 orders, CR-4117 Draft 0, CR-4118 Draft 0, CR-3902 Archived 3","Delete on CR-4117 opens \"Delete this record?\" showing \"Acme North · Draft · 0 orders · “Imported from CSV”\" and \"This permanently removes the record and cannot be undone.\"","\"Delete record\" shows \"Deleted CR-4117\"; \"3 records\"; CR-4118 remains"]},{"id":"FR-DEL-02","weight":"Could","text":"Cancel keeps the record; \"Reset demo\" restores deleted records.","accept":["Cancel closes the modal and the count stays 4","After deleting CR-3902, \"Reset demo\" shows \"Demo reset\" and \"4 records\""]}]},{"id":"TPL","title":"Notification templates","summary":"Searchable table of notification templates with create dialog.","reqs":[{"id":"FR-TPL-01","weight":"Should","text":"The template table filters by name or subject.","accept":["Initial rows: Bullet Template, Welcome Email, Password Reset, Invoice Reminder under app bar \"Notification Center\"","\"reset\": only Password Reset; \"due\": only Invoice Reminder (matched by subject); \"zzz\": \"No templates found\""]},{"id":"FR-TPL-02","weight":"Must","text":"Creating a template adds it with today's date and keeps it for the tab session.","accept":["\"Create New Template\" opens dialog \"New template\" with Template name, Subject and Message (\"Enter comments...\")","Shipping Notice / Your order shipped / Hi: dialog closes and the row appears with Last modified today","The row is still present after reload in the same tab"]},{"id":"FR-TPL-03","weight":"Should","text":"Template name and subject are required and names are unique case-insensitively.","accept":["Empty: role=alert \"Template name is required\"","Name only: \"Subject is required\"","\"welcome email\": \"A template with this name already exists\" and the dialog stays open"]}]},{"id":"SSO","title":"Single sign-on to templates","summary":"Multi-step sign-in in front of the template screen.","reqs":[{"id":"FR-SSO-01","weight":"Must","text":"The multi-step sign-in (email, password, \"Stay signed in?\") ends on the Notification Center templates table and the session survives reload.","accept":["Email step label \"Email, phone, or Skype\", then \"Enter password\", then \"Stay signed in?\" with \"Don't show this again\", No / Yes","Yes shows the templates table; reload keeps it without signing in again"]},{"id":"FR-SSO-02","weight":"Should","text":"Each sign-in step reports its own error.","accept":["Malformed identifier: \"Enter a valid email address, phone number, or Skype name.\"","Unknown account: \"We couldn't find an account with that username.\"","Wrong password: \"Your account or password is incorrect.\""]}]},{"id":"HLP","title":"Help centre","summary":"ParcelHelp articles, topics menu and order tracking.","reqs":[{"id":"FR-HLP-01","weight":"Could","text":"Inline links, the help icon and the Topics menu open the correct articles.","accept":["Home: logo \"ParcelHelp\", hero \"How can we help?\"","\"damaged or missing items policy\": report within 30 days, replacement or refund within 5 business days, with \"← Back to Help Center\"","The \"?\" icon opens \"Finding your order number\": ORD- followed by 8 digits","Topics > \"Contact support\": phone 1-800-555-0199 (Mon–Fri, 8am–8pm ET)","An unknown article slug shows \"Article not found\""]},{"id":"FR-HLP-02","weight":"Should","text":"The Billing \"invoice guide\" link opens \"Reading your invoice\" in a new tab and leaves the first tab on the Help Center home."},{"id":"FR-HLP-03","weight":"Could","text":"Track is enabled only for an order number of \"ORD-\" plus exactly 8 digits.","accept":["ORD-12345678 enables Track","ORD-1234567, ORD-123456789 and ord-12345678 leave it disabled"]}]},{"id":"INT","title":"Intake packet","summary":"Reconcile a 100-field draft against the scanned source and submit.","reqs":[{"id":"FR-INT-01","weight":"Should","text":"The seeded draft differs from the source in 9 fields; Audit draft reports the match count and an incomplete packet is rejected.","accept":["Audit draft: \"91 of 100 fields match\"","Submit packet: \"Packet PKT-100 rejected: 91 of 100 fields match\""]},{"id":"FR-INT-02","weight":"Must","text":"After the mismatched fields (f005, f014, f023, f038, f049, f060, f072, f087, f094) are corrected to the source values, every edit is kept and the packet is accepted.","accept":["Audit draft: \"100 of 100 fields match\"","Submit packet: \"Packet PKT-100 accepted, all 100 fields match\"","Reset restores the draft: Audit shows \"91 of 100 fields match\" again"]}]},{"id":"MKT","title":"Marketing site and demo booking","summary":"Vantage product pages, demo request form and slot booking.","reqs":[{"id":"FR-MKT-01","weight":"Could","text":"The \"Vantage Platform\" mega menu opens product pages; other menus show \"Coming soon\".","accept":["Mega menu groups Test automation, Test management and AI with 7 product links","A product page has eyebrow \"Vantage Platform · <product>\", four feature sections and a mid-page \"Book a demo\""]},{"id":"FR-MKT-02","weight":"Should","text":"The demo form enforces its required fields and business-email rule.","accept":["Empty submit: \"Please select a product.\", \"Please enter a valid business email.\", \"Please complete this required field.\", \"Please enter a number of testers.\", \"Please select a country.\"","qa@freemail.com: \"Please use your business email.\"","Testers \"0\": \"Please enter a number of testers.\""]},{"id":"FR-MKT-03","weight":"Must","text":"A valid demo request leads to a calendar where a free weekday slot can be booked.","accept":["The form shows \"Interested in <product>?\"; after submit \"Pick a time with a product specialist\" with past days and weekends disabled","Confirm shows \"You're booked!\" and \"BK-…: <date> at <time>. A calendar invite is on its way.\""]},{"id":"FR-MKT-04","weight":"Should","text":"The slot API returns per-day slots, no weekend slots and refuses double booking.","accept":["GET /api/hub/vantage-site/slots?date=2026-10-20: [\"09:30\",\"10:00\",\"11:00\",\"14:00\",\"15:30\",\"16:00\"]","Saturday 2026-10-24: []; date \"20261020\": 400 \"date must be YYYY-MM-DD\"","POST demo-requests: 201 DEMO-…; POST bookings: 201 BK-…; the same slot again: 409 \"That time was just taken\" and it no longer appears in slots","Unknown requestId: 404 \"Demo request not found\"; missing date/time: 422 \"Pick a date and a time\""]}]},{"id":"PAT","title":"Clinic appointment booking","summary":"Willow Health: sign-in, appointment booking and history.","reqs":[{"id":"FR-PAT-01","weight":"Must","text":"The scenario opens already signed in as Nadia Okafor; after Log out, protected pages ask for login and the demo account can sign in again with its exact name.","accept":["Header \"Willow Health\", \"Signed in as Nadia Okafor\", nav Make Appointment, History, Logout","Logout: \"You are signed out\"; Make Appointment shows the Login form","A name in the wrong letter case (nadia okafor): \"Login failed! Please ensure the username and password are valid.\"","The correct name and password land on \"Make Appointment\""]},{"id":"FR-PAT-02","weight":"Must","text":"A booking confirmation and the History list reflect the chosen facility, program and date.","accept":["Willow Riverside Hospital, Medicaid, 24/12/2026: \"Appointment Confirmation\" shows Facility, Healthcare Program and Visit Date accordingly","History lists \"24/12/2026 — Willow Riverside Hospital (Medicaid)\""]},{"id":"FR-PAT-03","weight":"Could","text":"Visit Date must be dd/mm/yyyy.","accept":["Empty or \"2026-12-24\": \"Visit Date is required (dd/mm/yyyy)\" and nothing is added to History"]}]},{"id":"POL","title":"Policy register","summary":"Register of 24 policies with look-alike titles.","reqs":[{"id":"FR-POL-01","weight":"Should","text":"Each policy opens its own detail page even when titles are identical.","accept":["24 rows; POL-137 and POL-173 are both titled \"Regional data retention addendum\"","POL-137: crumb \"Policy register / POL-137\", Region APAC, Revision 8, Status Needs review, Owner M. Okafor, Last updated 2026-09-02","POL-173: Region EMEA, Revision 5, Status Active"]}]},{"id":"HND","title":"Order handoff","summary":"Three-step flow where the handoff code shown on step 1 must be re-entered on step 3.","reqs":[{"id":"FR-HND-01","weight":"Must","text":"The handoff completes when the receipt code is re-entered and the delivery city has been changed to Austin.","accept":["Steps Receipt / Shipping details / Verify handoff for \"Order OR-4821\" with 3 items; the receipt shows \"Handoff code: HX-7319 — you will need this code at the end. It is only shown here.\"","Back is disabled on Shipping; Verify shows \"Delivery city: Austin\"","Code HX-7319: \"Handoff verified\" and \"Order OR-4821 will be delivered to Austin.\""]},{"id":"FR-HND-02","weight":"Could","text":"POST /api/hub/remember-code/handoff refuses wrong codes and cities; the code is case-insensitive.","accept":["Wrong code: 422 \"Handoff code does not match the receipt\"","City Dallas: 422 \"Delivery city Dallas does not match the shipping manifest\"","hx-7319 + Austin: 200 \"Handoff verified\"; unknown order: 404 \"Order not found\""]}]},{"id":"RPT","title":"Report queue","summary":"800 reports with search, filters, review and paging.","reqs":[{"id":"FR-RPT-01","weight":"Should","text":"Search and filters narrow the 800 reports to the exact subset.","accept":["Initial \"800 reports\", \"Page 1 of 32\", Reviewed this session 0","\"Quarterly Controls\": 133 reports; plus Region APAC: 31; plus Cycle 2026-Q3: 7","RPT-0673 (K. Tanaka, Pending) is listed; RPT-0637 (2026-Q2) and RPT-0674 (EMEA) are not"]},{"id":"FR-RPT-02","weight":"Should","text":"Mark reviewed changes only that report and updates the counter and activity feed.","accept":["RPT-0673: Quarterly Controls, APAC, 2026-Q3, Pending, owner K. Tanaka","After \"Mark reviewed\": Status Reviewed, button gone, activity \"You reviewed RPT-0673 · just now\", Reviewed this session 1","RPT-0637 stays Pending"]},{"id":"FR-RPT-03","weight":"Could","text":"The reports API pages and filters correctly.","accept":["Default: total 800, pageSize 25, first row RPT-0001","pageSize=500 caps at 100; page=33 returns 0 rows with total 800","status=Pending total 426; region=APAC total 198","Review of RPT-9999: 404 \"Report not found\""]}]},{"id":"STK","title":"Stock locations","summary":"SKU table with an inline location editor.","reqs":[{"id":"FR-STK-01","weight":"Should","text":"The inline editor moves only the chosen SKU to a known location.","accept":["North dock holds SKU-017 Void fill and SKU-071 Packing tape","Editing SKU-071 to \"Cold room\" shows role=status \"Location saved: SKU-071 moved to Cold room\"; SKU-017 is unchanged","Escape closes the editor without saving"]},{"id":"FR-STK-02","weight":"Could","text":"Free-text or unknown locations are refused (names are case-sensitive) and the demo can be reset.","accept":["\"cold room\": \"Unknown location cold room\"; the location is unchanged","PATCH on SKU-999: 404 \"SKU not found\"","\"Reset demo\": \"Demo data reset\" and SKU-071 returns to North dock"]},{"id":"FR-STK-03","weight":"Could","text":"A saved move persists across reload in the same tab.","accept":["After moving SKU-071 to South dock and reloading, \"Page load: Reloaded\" and SKU-071 is still in South dock"]}]},{"id":"TRV","title":"Travel agent portal","summary":"German-language portal with password plus 6-digit 2FA.","reqs":[{"id":"FR-TRV-01","weight":"Must","text":"Password then a valid 6-digit code opens the dashboard.","accept":["Valid password (\"Anmelden\") leads to \"Zwei-Faktor-Authentifizierung\"; valid code (\"Bestätigen\") to ?page=dashboard, \"Agent Dashboard\", \"Willkommen zurück, Demo Agent.\" with KPIs 12 / 4 / € 18.420","\"Buchungen\" lists TW-2201 Familie Becker Mallorca 24.10.2026 and TW-2207 Jana Schulz Lissabon 02.11.2026","\"Menü\" > \"Abmelden\" returns to \"Anmelden\""]},{"id":"FR-TRV-02","weight":"Should","text":"Wrong credentials or code are refused and protected pages need the full sign-in.","accept":["Wrong password: role=alert \"E-Mail oder Passwort ist falsch.\"","Wrong code: \"Der Code ist ungültig.\"","Opening ?page=bookings in a fresh session shows the login form","POST otp without login: 409 \"Bitte zuerst anmelden.\""]}]},{"id":"WRK","title":"Work queue","summary":"Six cards routed to Inbox, Urgent, Review and Archive by drag and drop or a Move-to select.","reqs":[{"id":"FR-WRK-01","weight":"Should","text":"Cards can be dragged between queues and Check assignments reports how many are misplaced.","accept":["Initially all six cards are in Inbox and Check reports \"3 of 6 cards are in the wrong queue.\"","INC-104 to Urgent, INV-207 to Review, BAK-309 to Archive gives counts Inbox 3, Urgent 1, Review 1, Archive 1 and \"All six cards are in the correct queues.\""]},{"id":"FR-WRK-02","weight":"Could","text":"The \"Move to\" select is a keyboard alternative to dragging and Reset returns every card to Inbox.","accept":["Moving INC-140 to Urgent as well makes Check report \"1 of 6 cards are in the wrong queue.\"","Reset puts all cards in Inbox with no result message"]}]},{"id":"YDS","title":"Turkish quiz login","summary":"LinguaYDS sign-in with format rules.","reqs":[{"id":"FR-YDS-01","weight":"Should","text":"Any well-formed username and password signs in and logout returns to the form.","accept":["Header \"LinguaYDS\" with the username; \"Kategori Seç\": Kelime Bilgisi, Dil Bilgisi, Okuma, Çeviri, Cloze Test","\"Kelimeler\": \"Bu bölüm yakında.\"","\"Çıkış\" returns to the \"Giriş Yap\" form"]},{"id":"FR-YDS-02","weight":"Could","text":"Username needs at least 6 alphanumerics and password 6 to 12 alphanumerics; every failure shows the same message.","accept":["\"ab12\"/\"abc123\", \"abcdef\"/\"abc12\", \"abcdef\"/\"abc1234567890\" and \"abc-def\"/\"abc123\": role=alert \"Kullanıcı bulunamadı\"","\"abcdef\"/\"abc123\" signs in"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Scenario state is isolated per browser tab or context: changes in one are never visible in another, and each scenario can be reset to its seeded fixture.","accept":["Moving INC-104 and deleting CR-4117 in one context leaves a new context with all cards in Inbox and \"4 records\""]}],"glossary":[{"term":"Look-alike record","meaning":"A record with a near-identical id or title (for example POL-137 and POL-173) that must not be confused with the target."},{"term":"Reset","meaning":"Control that restores a scenario's seeded data for the current tab."}],"outOfScope":["Placeholder links (Forgot password, Log in, Start free trial, Profile/Einstellungen)","Marketing copy and imagery","Help Center search box"]},{"ns":"insights","title":"Lumen Insights","version":"4.3","summary":"Lumen Insights is a sales dashboard over an order warehouse with data through 30 Sep 2026. Analysts see KPI tiles with the change against the previous period, pick a date range, slice and cross-filter the charts, drill from Region to Country to Customer, browse and export the matching order lines, save views, and schedule report subscriptions. Editors may save views; Viewers may only look.","problem":"Decision makers act on the numbers on the screen, so every total, delta, filter and export must agree with the underlying orders, and the same range and filters must always give the same figures.","personas":[{"name":"Analyst (Editor)","need":"Slice the quarter, drill into a region, export the matching rows, and keep the view or schedule it for leadership."},{"name":"Viewer","need":"Read the dashboard and export data without being able to change shared content."},{"name":"Leadership recipient","need":"Receive a regular digest with the agreed schedule, format and recipients."}],"goals":["KPI values, deltas and totals are exact and consistent between tiles, charts, tables, exports and the query service.","Every filter narrows every visual, the order lines and the export in the same way.","Only authorised users can read data; only Editors can save views."],"features":[{"id":"AUTH","title":"Sign-in and roles","summary":"Bearer sign-in, session and Editor versus Viewer.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"A user signs in with work email and password and lands on \"Sales overview\" for the current quarter, showing role and name.","accept":["Sign-in form has \"Work email\", \"Password\" and \"Sign in\"","Editor sees \"Editor\" and \"Priya Analyst\"","The date button reads \"This quarter: Jul 1, 2026 – Sep 30, 2026\"; sidebar shows Sales overview, Saved views, Subscriptions and \"Data through Sep 30, 2026\""]},{"id":"FR-AUTH-02","weight":"Must","text":"Wrong credentials give a generic message and keep the user on the form; data endpoints refuse requests without a valid token.","accept":["UI: alert \"Incorrect email or password.\"","Service login with a wrong password: 401 \"Invalid credentials\"","Reading KPIs without a token: 401","The public metadata call needs no token and reports anchor 2026-09-30, data from 2025-10-01"]},{"id":"FR-AUTH-03","weight":"Should","text":"The session is kept in the tab across reloads and dropped on sign-out.","accept":["After a reload the dashboard is still shown","After \"Sign out\" the form is shown and stays shown after another reload"]},{"id":"FR-AUTH-04","weight":"Should","text":"Viewers cannot save views: the \"Save view\" button is absent and the service refuses the request.","accept":["Viewer header shows \"Viewer\" and \"Victor Viewer\"; date range, \"Filters\" and \"Export CSV\" remain","Saving a view through the service with a Viewer token returns 403 and creates no view"]}]},{"id":"KPI","title":"KPI tiles","summary":"Six tiles with change against the previous period.","reqs":[{"id":"FR-KPI-01","weight":"Must","text":"Six KPI tiles show the value for the range and the percentage change against the previous period of the same length, labelled \"vs prev. period\".","accept":["This quarter: Revenue $15.1M ▲ 5.7%; Orders 1,766 ▲ 2.3%; Avg order value $8,540 ▲ 3.4%; Gross margin 54.4% ▼ 2.9%; Return rate 2.43% ▼ 25.0%; Active customers 412 ■ 0.0%","Revenue tooltip: \"Previous period Mar 31, 2026 – Jun 30, 2026: $14.3M\"","Service values: revenue 15082257.84, orders 1766, aov 8540.35, marginPct 54.39, returnRatePct 2.43, customers 412; deltaPct 5.69, 2.26, 3.36, -2.89, -25, 0","A fall is shown as ▼ and a rise as ▲; the sign is never inverted"]},{"id":"FR-KPI-02","weight":"Should","text":"Delta colour follows whether the change is good for the metric: a fall in return rate is good, a fall in margin is bad.","accept":["Revenue, Orders, Avg order value and Return rate ▼: good (green)","Gross margin ▼: bad (red); Active customers ■: neutral"]},{"id":"FR-KPI-03","weight":"Could","text":"When the previous period starts before the data does, every delta reads \"—\".","accept":["Last 12 months: Revenue $51.5M, Orders 5,978, Active customers 420, all deltas \"—\"; \"Order lines (5,978)\", \"Page 1 of 399\""]}]},{"id":"DATE","title":"Date range","summary":"Presets and custom range.","reqs":[{"id":"FR-DATE-01","weight":"Should","text":"The date picker offers seven presets, and choosing one updates the range and every visual.","accept":["Presets: Last 7 days, Last 30 days, Last 90 days, This quarter, Last quarter, Year to date, Last 12 months; \"This quarter\" is highlighted","Last 30 days: \"Sep 1, 2026 – Sep 30, 2026\", Revenue $5.1M, Orders 606, Active customers 330, \"Order lines (606)\""]},{"id":"FR-DATE-02","weight":"Could","text":"A custom range is picked with two clicks; Apply is disabled until both ends are set; later days than the data are disabled; the ends swap if clicked in reverse.","accept":["Footer \"Sep 1, 2026 → pick an end date\" with Apply disabled; applied range \"Custom: Sep 1, 2026 – Sep 30, 2026\"","Days after Sep 30, 2026 are disabled; Escape closes without changes","Clicking Sep 30 then Sep 1 reads \"Sep 1, 2026 → Sep 30, 2026\""]},{"id":"FR-DATE-03","weight":"Should","text":"A range whose start is after its end is rejected by the service.","accept":["422 \"from must be on or before to\"","A date not written YYYY-MM-DD: 422 \"from must be YYYY-MM-DD\""]}]},{"id":"FLT","title":"Slicers, charts and drill-down","summary":"Filters, chips, cross-filter and drill-down.","reqs":[{"id":"FR-FLT-01","weight":"Should","text":"A slicer value filters KPIs, charts and order lines together and appears as a removable chip; removing the chip restores the figures.","accept":["Region options North America, EMEA, APAC, LATAM; ticking EMEA shows chip \"region: EMEA\" and \"Filters (1)\"","EMEA: Revenue $4.2M, Orders 494 ▼ 4.8%, Return rate 2.63% ▼ 31.7%, \"Order lines (494)\"","Removing the chip returns Orders to 1,766","Segment slicer search \"mid\" lists only Mid-market; two ticked reads \"Segment 2 selected\""]},{"id":"FR-FLT-02","weight":"Should","text":"The region chart drills Region to Country to Customer one level per click, and the breadcrumb drills back up.","accept":["\"Revenue by region\" table: North America 5,996,273.25 / 711; EMEA 4,218,125.87 / 494; APAC 3,686,080.37 / 405; LATAM 1,181,778.35 / 156","EMEA click: \"Revenue by country — EMEA\", breadcrumb \"All regions › EMEA\", first bar United Kingdom","United Kingdom click: \"Top customers in United Kingdom\", first Nakatomi Partners, chips region and country","\"All regions\" returns to \"Revenue by region\" with no chips"]},{"id":"FR-FLT-03","weight":"Could","text":"Clicking a channel slice adds the channel as a filter and clicking it again removes it; \"Clear all\" removes slicers but keeps the date range.","accept":["Channel mix table: Mobile 4,039,681.25; Partner 3,053,560.84; Retail 1,947,156.35; Web 6,041,859.4","Web slice: chip \"channel: Web\", Orders 698, Active customers 345","\"Clear all\" after Last 30 days with two filters leaves \"Last 30 days: Sep 1, 2026 – Sep 30, 2026\" and Orders 606"]},{"id":"FR-FLT-04","weight":"Could","text":"The revenue trend switches between weekly and monthly buckets.","accept":["Monthly table: 2026-07 5,111,854.65; 2026-08 4,872,652.4; 2026-09 5,097,750.79"]}]},{"id":"ORD","title":"Order lines and export","summary":"Drill-through table, search and CSV.","reqs":[{"id":"FR-ORD-01","weight":"Should","text":"The order lines table shows 15 rows per page, newest first, and sorts by any column.","accept":["\"Order lines (1,766)\", \"Page 1 of 118\"; first ORD-05958 2026-09-30 Wonka Energy","Revenue descending: first ORD-04448 $118,365; ascending: first ORD-05245 $267","Last page \"Page 118 of 118\" has 11 rows and \"Next ›\" is disabled"]},{"id":"FR-ORD-02","weight":"Could","text":"Search matches order id, customer and product.","accept":["\"nakatomi partners\": \"Order lines (12)\"; \"ORD-05958\": \"Order lines (1)\"","No match: \"No orders match the current filters.\""]},{"id":"FR-ORD-03","weight":"Must","text":"Export CSV contains exactly the order lines that match the current range and filters, and the confirmation count equals the rows in the file.","accept":["EMEA and Partner: \"Order lines (100)\", \"Page 1 of 7\"","Downloads orders_2026-07-01_2026-09-30.csv with toast \"Exported 100 rows to orders_2026-07-01_2026-09-30.csv\"","Header id,date,customer,product,category,region,country,channel,segment,units,revenue,cost,returned and 100 data rows, all EMEA / Partner","Unfiltered export has 1,766 data rows"]}]},{"id":"VWS","title":"Saved views","summary":"Save, open and delete named views.","reqs":[{"id":"FR-VWS-01","weight":"Must","text":"An Editor saves the current range and filters as a named view and finds it on the Saved views page, sorted by name.","accept":["Dialog \"Save view\": \"Saves the date range and filters: Jul 1, 2026 – Sep 30, 2026, 1 filter(s).\"","Empty name: \"Give the view a name.\"","Toast \"Saved view “APAC Q3” (VW-NNNN)\"; cards APAC Q3, EMEA partner deep-dive, Global — last 12 months"]},{"id":"FR-VWS-02","weight":"Should","text":"Opening a view restores its range and filters on the dashboard.","accept":["\"EMEA partner deep-dive\" (VW-0002): \"Custom: May 1, 2026 – Sep 30, 2026\", chips region: EMEA and channel: Partner, \"Order lines (178)\"","\"Global — last 12 months\" (VW-0001): \"Last 12 months: Oct 1, 2025 – Sep 30, 2026\", no chips, Orders 5,978"]},{"id":"FR-VWS-03","weight":"Could","text":"Deleting a view removes its card and leaves the others."}]},{"id":"SUB","title":"Report subscriptions","summary":"Scheduled report digests.","reqs":[{"id":"FR-SUB-01","weight":"Should","text":"The Subscriptions page lists each schedule in words with format and recipients.","accept":["\"Monday revenue digest\" | \"Weekly on Monday at 08:00\" | PDF | leadership@lumen.io"]},{"id":"FR-SUB-02","weight":"Should","text":"The form validates the name, recipients and email addresses and defaults to Weekly, Monday, 08:00, PDF.","accept":["Empty form: \"Name is required\" and \"Add at least one recipient\"","\"not-an-email\": \"“not-an-email” is not a valid email address\" and no chip is added"]},{"id":"FR-SUB-03","weight":"Must","text":"A monthly subscription takes a day of month (1 to 28), a time, a format and recipients, and is listed with the schedule in words.","accept":["Cadence Monthly replaces \"Day\" with \"Day of month\" (1–28)","Day 5, 09:30, Excel, ops@lumen.io and cfo@lumen.io: row \"APAC monthly\" | \"Monthly on day 5 at 09:30\" | Excel | \"ops@lumen.io, cfo@lumen.io\""]},{"id":"FR-SUB-04","weight":"Could","text":"A subscription can be edited and deleted.","accept":["Edit shows the form pre-filled with \"Save changes\"; Day Friday gives \"Weekly on Friday at 08:00\"","Delete removes the row"]}]},{"id":"QRY","title":"Query service","summary":"Grouped aggregation and its validation.","reqs":[{"id":"FR-QRY-01","weight":"Must","text":"The query service returns grouped rows and a totals row, and the totals equal the sum of the rows.","accept":["By region for 2026-07-01 to 2026-09-30: North America 5996273.25/711, EMEA 4218125.87/494, APAC 3686080.37/405, LATAM 1181778.35/156; totals sum(revenue) 15082257.84, count 1766","EMEA by country: five countries, first United Kingdom 1369561.73; totals 4218125.87"]},{"id":"FR-QRY-02","weight":"Should","text":"An invalid query is rejected with a precise 422 message.","accept":["Unknown dimension: 422 \"Unknown dimension \\\"planet\\\". Allowed: region, country, …\"","Unknown measure: 422 \"Unknown measure \\\"median(revenue)\\\"\"","Unknown field: 422 \"Unknown field \\\"price\\\" in sum(price). Allowed: revenue, cost, …\""]}]}],"nfr":[],"glossary":[{"term":"Previous period","meaning":"The period of the same length that ends the day before the selected range starts."},{"term":"Slicer","meaning":"A filter control for one field, such as Region or Channel."},{"term":"Drill-through","meaning":"The order lines that make up the current figures."}],"outOfScope":["Chart rendering details beyond labels and the \"Show as table\" data","Actual delivery of subscription emails"]},{"ns":"metrics","title":"Pulse Metrics","version":"7.1","summary":"Pulse Metrics is a product-analytics workspace. After connecting a project with its token, a product team builds conversion funnels, reads weekly retention cohorts, charts event counts with release annotations, watches a live event feed, opens user profiles, and keeps saved reports and cohorts. The demo project \"Shopfront (production)\" holds 3,200 users and 23,929 events from 2026-04-06 to 2026-09-30.","problem":"Product teams make release and growth decisions from these numbers, so funnel, retention and count figures must be exact for the chosen steps, window, filters and dates, and must let a team find a regression such as a checkout drop on one platform.","personas":[{"name":"Product manager","need":"Build a funnel, compare platforms and date ranges, and save the analysis as a report."},{"name":"Growth analyst","need":"Read retention by cohort and segment events by interval and property."},{"name":"Support engineer","need":"Follow a live event to the user behind it and read that user’s timeline."}],"goals":["Funnel and retention numbers are exact for every combination of steps, window, breakdown and filters.","A team can trace a conversion drop to a platform and a release date.","Saved reports reopen with the same parameters and numbers."],"features":[{"id":"CON","title":"Connecting a project","summary":"Project-token gate and disconnect.","reqs":[{"id":"FR-CON-01","weight":"Must","text":"Entering the project token and choosing \"Connect\" opens the workspace on Funnels with the project name and totals in the header.","accept":["Before connecting: heading \"Connect a project\", field \"Project token\", \"Connect\" disabled while the field is empty","Sidebar: Funnels, Retention, Insights, Live view, Reports; header \"Shopfront (production)\" with \"3,200 users · 23,929 events\"","Tab title \"Funnels · Pulse Metrics\""]},{"id":"FR-CON-02","weight":"Must","text":"A wrong token never opens the workspace; surrounding spaces in a correct token are ignored.","accept":["Error \"That project token was rejected.\" and the connect screen stays","A correct token with leading and trailing spaces connects","The analytics API refuses a wrong key with 401 \"unauthorized\""]},{"id":"FR-CON-03","weight":"Could","text":"\"Disconnect\" returns to the empty connect screen.","accept":["Heading \"Connect a project\" and an empty token field"]}]},{"id":"FUN","title":"Funnels","summary":"Step builder, window, breakdown, dates and the resulting numbers.","reqs":[{"id":"FR-FUN-01","weight":"Must","text":"The default funnel counts users who did step 1 in the date range and completed each next step within the window of step 1.","accept":["Defaults: Sign up, View product, Add to cart, Start checkout, Purchase; 2026-08-02 to 2026-09-30; window 7 days; Breakdown None","\"All users\": Sign up 1,340; View product 1,062 (79.3% overall); Add to cart 552 (41.2% overall · 52.0% step); Start checkout 386 (28.8% · 69.9% step); Purchase 205 (15.3% · 53.1% step)","Stat card \"All users\" 15.3% \"205 of 1,340 converted\"","Caption \"Users who did step 1 between 2026-08-02 and 2026-09-30, completing each next step within 7 day(s) of step 1.\""]},{"id":"FR-FUN-02","weight":"Must","text":"The conversion window changes the result: a shorter window converts fewer users and a longer one more, and the caption names the window used.","accept":["1 day: View product 739 (55.1%), Purchase 16 (1.2%), caption \"within 1 day(s)\"","30 days: View product 1,151 (85.9%), Purchase 206 (15.4%), caption \"within 30 day(s)\"","The service clamps a window above 30 to 30"]},{"id":"FR-FUN-03","weight":"Should","text":"A breakdown splits the funnel by platform, plan or country, with one row and one overall figure per value.","accept":["Platform: Android 7.7% (35 of 455); iOS 19.1% (99 of 519); Web 19.4% (71 of 366); Purchase step Android 25.9%, iOS 67.8%, Web 67.6%","Plan: Free 14.4% (157 of 1,088); Pro 19.1% (48 of 252)","The service accepts breakdown platform, country or plan and otherwise answers 422 \"breakdown must be one of platform, country, plan\""]},{"id":"FR-FUN-04","weight":"Must","text":"Changing the date range allows the Android checkout drop to be tied to the release of 2026-08-17.","accept":["2026-06-01 to 2026-08-16 by platform: Android Purchase step 69.3% (106 users), iOS 67.8%, Web 59.4%","2026-08-17 to 2026-09-30: Android Purchase step 16.0% (17 of 106 who started checkout), iOS 68.3%, Web 72.6%","Insights lists the annotation 2026-08-17 \"Release 3.8.0 (new checkout)\""]},{"id":"FR-FUN-05","weight":"Should","text":"Steps can be removed, reordered with arrow buttons, and added, within 2 to 8 steps; order matters for the result.","accept":["Removing down to two steps disables every remove button; Sign up to Purchase: 1,340 to 205 (15.3%)","\"Move step 2 up\" gives Purchase then Sign up: Purchase 213 to Sign up 2 (0.9%); first \"up\" and last \"down\" are disabled","\"+ Add step\" three times gives 8 steps, then it is disabled; new steps default to \"Sign up\"","The service refuses one step: 422 \"A funnel needs at least 2 steps\" and an unknown name: 422 \"Unknown event \\\"Signup\\\"\""]},{"id":"FR-FUN-06","weight":"Could","text":"Steps can also be reordered by dragging a step onto another; the results table follows the new order.","accept":["Dragging Purchase onto Sign up gives Purchase, Sign up, View product, Add to cart, Start checkout; the table header starts \"1. Purchase\""]},{"id":"FR-FUN-07","weight":"Could","text":"The date inputs are bounded by the dataset.","accept":["From is not before 2026-04-06 and not after To; To is not after 2026-09-30 and not before From"]}]},{"id":"RET","title":"Retention","summary":"Weekly cohorts and filters.","reqs":[{"id":"FR-RET-01","weight":"Must","text":"Retention shows weekly cohorts with every cohort at 100.0% in week 0, followed by the share still active each later week; weeks not yet reached stay empty.","accept":["Defaults: cohorts from 2026-06-01, Platform All, Plan All, Weeks 8; columns W0 to W8; 18 cohort rows","Average row: Users 2,482; W0 100.0%, W1 47.8%, W2 43.3%, W4 36.7%, W8 24.1%","Cohort 2026-06-01: 124 users, W0 100.0%, W1 52.4%, W8 28.2%","Cohort 2026-09-28: 81 users, W0 100.0%, W1 to W8 empty with the hover \"Not reached yet\""]},{"id":"FR-RET-02","weight":"Should","text":"Platform and plan filters combine and apply to every cohort.","accept":["Android and Pro: cohort 2026-06-01 has 9 users; W1 66.7%; W3 33.3%; W8 22.2%"]},{"id":"FR-RET-03","weight":"Could","text":"The Weeks selector sets the number of columns.","accept":["Weeks 4 gives W0 to W4; Weeks 12 gives W0 to W12"]}]},{"id":"INS","title":"Insights","summary":"Event counts over time with annotations.","reqs":[{"id":"FR-INS-01","weight":"Must","text":"The default insight counts Purchase events by platform per week for the last 90 days and marks annotations on the axis.","accept":["Defaults: Event Purchase, Measure Total events, Breakdown platform, Interval Week, Range Last 90 days, chart Line","Legend totals: Android 87, iOS 152, Web 108","14 weekly buckets from 2026-06-29 to 2026-09-28; a red dashed line marks the 2026-08-17 bucket"]},{"id":"FR-INS-02","weight":"Should","text":"Unique users counts each user once; when no user purchased twice it equals the total events.","accept":["Breakdown None, Unique users and Total events both read \"Purchase 347\" (87 + 152 + 108)"]},{"id":"FR-INS-03","weight":"Should","text":"The month interval aggregates the whole range.","accept":["Last 6 months by month: 2026-04 to 2026-09 with 42, 87, 108, 136, 108, 108; legend \"Purchase 589\"","Daily Sign up for 2026-09-01 to 2026-09-30 gives 30 buckets and a total of 682 (service)","Unknown event: 422 \"Unknown event \\\"Nope\\\"\""]},{"id":"FR-INS-04","weight":"Could","text":"Legend items toggle a series on and off.","accept":["Clicking \"Android\" greys it (aria-pressed false) and hides its line; clicking again shows it, also in Stacked bar"]},{"id":"FR-INS-05","weight":"Should","text":"Annotations are listed in date order and can be added and deleted.","accept":["Seed: 2026-06-01 \"Summer campaign start\", then 2026-08-17 \"Release 3.8.0 (new checkout)\"","Adding 2026-09-01 \"Pricing page redesign\" lists three items, the new one last; delete returns to two"]}]},{"id":"LIV","title":"Live view and users","summary":"Event feed and user profiles.","reqs":[{"id":"FR-LIV-01","weight":"Should","text":"The live view lists the newest events, can be filtered by event and can be paused.","accept":["\"23,929 matching events · refreshing every 5s\"; first row \"Invite teammate\" Android · BR 2026-09-30","Event Purchase: \"589 matching events\" and every row is Purchase","\"❚❚ Pause\" becomes \"▶ Resume\" and the pulse dot is hidden"]},{"id":"FR-LIV-02","weight":"Could","text":"Expanding an event shows its properties and links to the user profile.","accept":["First row: distinct_id U-03187, $os Android, mp_country_code BR, plan Free","Clicking \"U-03187\" opens /users/U-03187, tab title \"User profile · Pulse Metrics\""]},{"id":"FR-LIV-03","weight":"Could","text":"A user profile shows properties and a timeline; an unknown user shows a clear error.","accept":["U-00042: Signed up 2026-09-20; Platform Android; Country IN; Plan Free; Funnel depth 2 / 5; Active weeks 1; Events 3; timeline starts 2026-09-20 with \"Sign up\"","\"Find user\" with \"U-99999\": \"No user with distinct_id “U-99999”.\"; the service answers 404 \"No user U-99999\""]}]},{"id":"REP","title":"Reports and cohorts","summary":"Saved analyses and named cohorts.","reqs":[{"id":"FR-REP-01","weight":"Must","text":"A funnel can be saved as a report, listed under Reports with its type and id, and reopened with the same parameters and numbers.","accept":["Platform breakdown, window 14 days: status \"Saved as RPT-0003\"","Listed \"Funnel: Sign up → Purchase by platform\" with badge \"funnel\"","Opening it restores Breakdown platform and window 14 days with Android, iOS and Web rows","Seed RPT-0001 \"Checkout funnel by platform\" opens /funnels?report=RPT-0001 with window 7 days and Android 7.7%, iOS 19.1%, Web 19.4%"]},{"id":"FR-REP-02","weight":"Should","text":"Reports can be searched, filtered by type, renamed and deleted.","accept":["Seed list sorted by name: \"Checkout funnel by platform\" (RPT-0001), \"Weekly retention — all users\" (RPT-0002)","Search \"weekly\" shows only RPT-0002; rename to \"Android checkout\" re-sorts it first; delete leaves RPT-0002","Type filter Insight with none shows \"No reports match.\""]},{"id":"FR-REP-03","weight":"Should","text":"Cohorts are created from one or more rules with a required name.","accept":["Seed: \"Android Pro\" — \"platform = Android AND plan = Pro\"","Empty name: \"Cohort name is required\"","Rules country is JP and plan is Pro named \"Pro users in Japan\" lists \"country = JP AND plan = Pro\" and resets the form"]},{"id":"FR-REP-04","weight":"Could","text":"A report must be of type funnel, retention or insight; any other type is rejected.","accept":["Missing name: 422 with missing [\"name\"]","Type \"zzz\": 422, type must be funnel, retention or insight, and nothing is stored"]}]}],"nfr":[],"glossary":[{"term":"Conversion window","meaning":"The number of days after step 1 within which each later step must happen to count."},{"term":"Cohort","meaning":"A group of users who first appeared in the same week (retention) or share property rules (saved cohort)."},{"term":"Annotation","meaning":"A dated note, such as a release, drawn on insight charts."}],"outOfScope":["Chart pixel rendering beyond legend and axes","Real-time ingestion (the dataset is fixed)"]},{"ns":"observe","title":"Beacon Observe","version":"1.0","summary":"An observability service for engineering teams: metric time series for services and environments, stat panels, deploy annotations, a searchable log explorer with level facets, and alert rules that evaluate live, with silences. This release exposes the service as an HTTP API over a frozen synthetic dataset whose clock stands at 30 Sep 2026 12:00 UTC.","problem":"During an incident, on-call engineers need to see quickly what changed, when and where, and trust that charts, stats, logs and alerts all tell the same story on the same time axis.","personas":[{"name":"On-call engineer","need":"See the error spike, the deploy that preceded it and the logs that explain it, then confirm the alert and its state."},{"name":"Service owner","need":"Define alert rules that fire only on sustained breaches and silence them during maintenance."},{"name":"Dashboard builder","need":"Pull series with a sensible resolution for any time range."}],"goals":["Time series are aligned to the requested window and use a resolution that keeps each series to a few hundred points.","Alerts fire only when the whole for-window breaches the threshold, and silences hide only active alerts.","Log facets always describe the whole result set for the other filters, not just the visible level.","Each caller session keeps its own rules, silences and dashboards."],"features":[{"id":"META","title":"Environment metadata","summary":"Published facts the client needs to build its controls.","reqs":[{"id":"FR-META-01","weight":"Must","text":"The meta endpoint publishes the clock, services, environments, metrics, ranges and the incident window.","accept":["The frozen now is 1790769600000 (30 Sep 2026 12:00:00 UTC)","Services are api-gateway, checkout, search and auth; environments production and staging; metrics latency_p95, rps, error_rate and cpu","There are 8 relative ranges from 15m to 7d","The incident is on service \"checkout\" from 09:40Z to 10:05Z on that day"]}]},{"id":"SER","title":"Time series","summary":"Metric values over a time window, per service and environment.","reqs":[{"id":"FR-SER-01","weight":"Should","text":"The resolution step adapts to the range so a series has at most about 720 points.","accept":["15m: step 15000 ms and 61 points","1h: step 15000 ms and 241 points; error_rate carries unit \"%\"; the last checkout value is 0.61","6h: step 60000 ms; without a service filter four series come back, one per service, 361 points each","7d: step 900000 ms and 673 points","Steps are 15 s up to 3 h, then 1 m, 5 m, 15 m and 1 h as the range grows"]},{"id":"FR-SER-02","weight":"Must","text":"Series timestamps are aligned to the window: the first point is at from and the last is at to, never beyond now.","accept":["For range 1h the first timestamp is now minus 3,600,000 ms and the last timestamp is now (1790769600000)","Every timestamp is a whole number of steps after the first, so a spike plots at its real time"]},{"id":"FR-SER-03","weight":"Should","text":"The checkout incident shows in the error rate only inside its window and only in production.","accept":["Before 09:40Z values are about 0.5-0.7; from 09:40Z to 10:05Z every value is between 7.5 and 11.5 (peak 11.42); after 10:05Z they return to about 0.5-0.7","In staging there is no spike (maximum 0.62)"]},{"id":"FR-SER-04","weight":"Should","text":"An unsupported metric, an unknown service or an inverted window is rejected with 422.","accept":["Metric mem: 422 \"metric must be one of latency_p95, rps, error_rate, cpu\"","Service billing: 422 Unknown service \"billing\"","from after to: 422 \"from must be before to\""]},{"id":"FR-SER-05","weight":"Should","text":"The window is clamped: the end never exceeds now and the start never goes back more than 7 days.","accept":["An end in the future is returned as now (1790769600000)","A start earlier than 7 days before now is returned as now minus 7 days"]}]},{"id":"STA","title":"Stat panels","summary":"Current, average and maximum of a metric per service over a range.","reqs":[{"id":"FR-STA-01","weight":"Should","text":"Stats report current, average and maximum per service for the chosen range.","accept":["error_rate over 6h (current / avg / max): checkout 0.61 / 1.24 / 11.42; api-gateway 0.37 / 0.4 / 0.46; search 0.32 / 0.3 / 0.34; auth 0.19 / 0.2 / 0.23","Over 1h checkout is 0.61 / 0.59 / 0.68 because the incident is outside the window; other services are unchanged"]}]},{"id":"ANN","title":"Deploy annotations","summary":"Deploy markers to overlay on charts.","reqs":[{"id":"FR-ANN-01","weight":"Could","text":"Annotations list the deploys within the range, bracketing the incident.","accept":["Over 6h there are two deploys, both of checkout: one by release-bot at 09:38Z and a rollback fix by m.ito at 10:02Z","Over 7d there are four deploys, adding one for search on 29 Sep and one for auth on 28 Sep"]}]},{"id":"LOG","title":"Log explorer","summary":"Search logs by service, time, level and text, with level facets.","reqs":[{"id":"FR-LOG-01","weight":"Should","text":"Logs for a service and window come back newest first and expose the root cause of the incident.","accept":["Checkout logs from 09:40Z to 10:05Z: 68 items, facets DEBUG 0, INFO 0, WARN 0, ERROR 68","The first item is level ERROR, message \"NullPointerException in CartPricer.apply\", traceId \"9cfe2913\""]},{"id":"FR-LOG-02","weight":"Should","text":"A level filter narrows the items but the facets still count every level; text search matches message or trace id; limit caps the items.","accept":["Last hour, level ERROR: 15 items, all ERROR; facets DEBUG 135, INFO 510, WARN 61, ERROR 15","Text \"502\" in the last hour: 2 items; facets INFO 1, ERROR 1","Over 6h with limit 5: exactly 5 items"]}]},{"id":"ALR","title":"Alert rules and silences","summary":"Rules evaluated live against the series, with a for-window and silences.","reqs":[{"id":"FR-ALR-01","weight":"Must","text":"The seeded rules are evaluated at now and report state, current value and when they last fired.","accept":["AR-0001 \"Checkout error budget burn\": OK, current 0.61, last fired 10:05Z (1790762700000)","AR-0002 \"Gateway latency\": OK, current 134.45, never fired","AR-0003 \"Search CPU saturation\": OK, current 61.25, never fired"]},{"id":"FR-ALR-02","weight":"Could","text":"An unsaved rule can be previewed without being stored; the operator is validated.","accept":["Checkout error_rate > 5 for 5 minutes previews as OK with current 0.61 and last fired 10:05Z","api-gateway latency_p95 < 1000 for 5 minutes previews as Firing with current 134.45","An operator other than > or < is rejected with 422 \"service, metric, op (> or <) and numeric threshold are required\"","Listing rules afterwards still shows only AR-0001 to AR-0003"]},{"id":"FR-ALR-03","weight":"Must","text":"A rule can be created and silenced; while a silence extends beyond now the rule shows as Silenced.","accept":["Creating \"Auth CPU low\" (auth cpu < 100 for 5 minutes) answers 201 with a new id AR-NNNN and the rule is Firing","Creating a silence with until 2026-09-30T18:00:00Z answers 201 with an id SIL-NNNN","The rule then shows Silenced with silencedUntil \"2026-09-30T18:00:00Z\""]},{"id":"FR-ALR-04","weight":"Should","text":"A silence that ended before now has no effect.","accept":["A silence until 2026-09-30T11:00:00Z is accepted with 201","The rule still shows Firing and silencedUntil is null"]},{"id":"FR-ALR-05","weight":"Must","text":"A rule fires only when every sample over its for-window breaches the threshold.","accept":["The rule \"Auth latency creeping\" (auth latency_p95 > 100 for 5 minutes) is OK with current 112.31, because the last two minutes breach 100 but the minutes 11:55 to 11:57 do not","The rule becomes Firing only once all samples of the 5-minute window breach"]},{"id":"FR-ALR-06","weight":"Should","text":"Rules and silences reject missing required fields with 422.","accept":["A rule without service, metric, op and threshold: 422 naming the missing fields","A silence without ruleId and until: 422 naming the missing fields"]}]},{"id":"SES","title":"Session isolation","summary":"Callers can keep private rules, silences and dashboards.","reqs":[{"id":"FR-SES-01","weight":"Should","text":"Rules and silences created under one session identifier are not visible to another.","accept":["A rule created under session A does not appear under session B, which sees only AR-0001 to AR-0003"]}]},{"id":"DSH","title":"Dashboards","summary":"Saved collections of panels.","reqs":[{"id":"FR-DSH-01","weight":"Could","text":"A seeded overview dashboard has one panel per metric.","accept":["One dashboard, DB-0001 \"Service overview\", with panels latency_p95, rps, error_rate and cpu"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"The dataset is deterministic: the same request at the frozen clock always returns the same values, and times are exchanged as epoch milliseconds."}],"glossary":[{"term":"For-window","meaning":"The number of minutes a threshold must be breached continuously before a rule fires."},{"term":"Facet","meaning":"A count of log items per level."},{"term":"Step","meaning":"The spacing between points in a series, in milliseconds."}],"outOfScope":["Dashboard user interface","Real data ingestion; data is synthetic and frozen"]},{"ns":"explorer","title":"Quarry Explorer","version":"1.0","summary":"A self-service business-intelligence service. Signed-in analysts browse a catalog of tables, build aggregate questions (filters, groups, measures, time buckets, pivots), run read-only SQL and save questions into collections. This release exposes the service as an HTTP API over a fixed demo warehouse of orders, customers, products and app users.","problem":"Analysts need trustworthy numbers without asking engineering for a query. Every figure must match across the question builder and SQL, totals must cover every row, and nobody may change the warehouse by accident.","personas":[{"name":"Analyst","need":"Ask questions of the data by grouping, filtering and pivoting, and get complete, correct results."},{"name":"SQL power user","need":"Run a SELECT query and get the same numbers as the builder, with a safe cap on result size."},{"name":"Viewer","need":"Read saved questions without being able to change anything."}],"goals":["Totals always include every row that matches, whatever is shown in the result window.","Results from the question builder and from SQL agree for the same question.","The warehouse is read-only: nothing a user sends can change data.","Invalid input fails with a specific, actionable message."],"features":[{"id":"AUTH","title":"Sign-in and roles","summary":"A cookie session guards every data endpoint.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"Every data endpoint requires a signed-in session; bad credentials are rejected.","accept":["A request to the table catalog without a session cookie answers 401","Signing in with a wrong password answers 401 \"Invalid credentials\"","A correct sign-in answers 200 with the user (for the demo analyst: Dana Data, role Admin) and sets a session cookie","The current-user endpoint with the cookie returns that user name"]},{"id":"FR-AUTH-02","weight":"Should","text":"The Viewer role is read-only: it may list saved questions but any attempt to save one is refused.","accept":["Signing in as the Viewer returns role Viewer","Listing questions answers 200 with the 4 seeded questions","Saving a question answers 403 and nothing is stored"]}]},{"id":"TBL","title":"Table catalog","summary":"The tables available to question, with their columns and the values in them.","reqs":[{"id":"FR-TBL-01","weight":"Should","text":"The catalog lists four tables with typed columns and row counts.","accept":["Orders (5,978 rows, 13 columns), customers (420 rows, 6 columns), products (24 rows, 4 columns) and App users (3,200 rows, 6 columns)","In orders, date is \"date\"; units, revenue and cost are \"number\"; returned is \"boolean\"; the rest are \"text\""]},{"id":"FR-TBL-02","weight":"Should","text":"Distinct values of a column are returned sorted, for filter pickers; unknown columns are not found.","accept":["orders.region returns [\"APAC\",\"EMEA\",\"LATAM\",\"North America\"]","orders.channel returns Mobile, Partner, Retail, Web; orders.category returns Hardware, Services, Software, Support","users.platform returns [\"Android\",\"Web\",\"iOS\"]","An unknown column answers 404 \"No column orders.nope\""]}]},{"id":"DAT","title":"Notebook questions","summary":"Aggregate questions: filters, dimensions, measures, sort, time buckets and pivots.","reqs":[{"id":"FR-DAT-01","weight":"Must","text":"Grouping by a dimension with measures and a sort returns one row per group, and totals cover every row.","accept":["Revenue and order count by region sorted by revenue descending: North America 20,311,610.13 / 2,389; EMEA 14,699,095.82 / 1,733; APAC 12,285,668.66 / 1,318; LATAM 4,251,167.66 / 538","Totals: sum(revenue) 51,547,542.27; count 5,978; rows 5,978","The groups always add up to the totals"]},{"id":"FR-DAT-02","weight":"Must","text":"Filters apply before grouping and also to the totals.","accept":["Region = EMEA by channel by revenue: Web 5,947,018.18 / 697; Mobile 3,839,953.05 / 432; Partner 3,066,579.33 / 366; Retail 1,845,545.26 / 238","Totals equal the EMEA row of the unfiltered region question: 14,699,095.82 and 1,733","Filtering returned = true and counting gives 181"]},{"id":"FR-DAT-03","weight":"Should","text":"A derived month dimension buckets dated rows by calendar month.","accept":["Order count by month gives 12 rows from 2025-10 (330) to 2026-09 (606), peaking at 2025-12 (577) and 2026-06 (719)","The monthly counts sum to 5,978"]},{"id":"FR-DAT-04","weight":"Must","text":"A two-dimension pivot returns every combination of the two dimensions, and the cells reconcile to the totals.","accept":["Region by channel with sum(units) returns 16 rows (4 regions x 4 channels) and total 16","sum(units) total is 15,378 and equals the sum of all 16 cells","Web cells: APAC 1,410; EMEA 1,734; LATAM 487; North America 2,510"]},{"id":"FR-DAT-05","weight":"Should","text":"A calculated margin is available as a field and other tables aggregate the same way.","accept":["By category, sum(revenue) and avg(margin): Hardware 11,240,884.75 / 2,140.84; Services 16,524,733.30 / 5,953.63; Software 14,696,417.53 / 5,254.29; Support 9,085,506.69 / 6,859.95","App users by platform: Android 1,082; Web 888; iOS 1,230 (total 3,200)"]},{"id":"FR-DAT-06","weight":"Could","text":"With no dimensions or measures a question returns raw rows, honouring a limit.","accept":["Products with limit 3 returns columns id, name, category, listPrice and three rows starting PRD-101 \"Edge Gateway X2\" Hardware 1890","totals.rows is 24, the full table size"]},{"id":"FR-DAT-07","weight":"Should","text":"Unknown tables, columns and measures are rejected with 422 and a specific message.","accept":["Unknown table: 422 Unknown table \"nope\"","A column not on the table: 422 Unknown column \"month\" on customers (it has no date column)","An unsupported measure: 422 Unknown measure, listing count, sum, avg, min, max and distinct"]}]},{"id":"SQL","title":"SQL editor","summary":"Read-only SQL over the warehouse with a capped result size.","reqs":[{"id":"FR-SQL-01","weight":"Must","text":"A SELECT returns its columns and rows, agreeing with the question builder.","accept":["Orders per region, highest first: North America 2389, EMEA 1733, APAC 1318, LATAM 538, truncated false","Top three customers by revenue: Tyrell Dynamics 781266.45, Massive Networks 718734.84, Wonka Holdings 702141.58; a trailing semicolon is accepted","MIN and MAX of the order date are 2025-10-01 and 2026-09-30"]},{"id":"FR-SQL-02","weight":"Must","text":"Results are capped by the requested limit and never exceed 2,000 rows; capped results are flagged as truncated.","accept":["limit 10 returns 10 rows, total 5978 and truncated true","No limit returns 2000 rows, total 5978 and truncated true","A limit of 50000 still returns 2000 rows"]},{"id":"FR-SQL-03","weight":"Must","text":"Only SELECT statements run; writes and broken SQL are rejected and data never changes.","accept":["DELETE: 422 read_only \"Only SELECT statements are allowed\"","An unknown column: 400 sql_error \"no such column: nope\"","Blank SQL: 422 \"sql is required\"","COUNT(*) on orders afterwards is still 5978"]}]},{"id":"QST","title":"Saved questions and collections","summary":"Questions are saved into collections, found again and re-run.","reqs":[{"id":"FR-QST-01","weight":"Could","text":"Saved questions live in collections and can be searched and filtered by collection.","accept":["Collections: COL-0001 Sales, COL-0002 Product, COL-0003 Our analytics","4 seeded questions: Q-0001 Revenue by region (bar), Q-0002 Monthly orders (line), Q-0003 Signups by platform (pie), Q-0004 Top customers (SQL) (table, with sql set and query null)","Searching \"region\" returns only Q-0001; the Sales collection returns 2"]},{"id":"FR-QST-02","weight":"Must","text":"A saved question can be fetched and its query re-run to give the same result.","accept":["Saving \"EMEA by channel\" answers 201 with a new id Q-NNNN","Re-running its query gives Web 697, Mobile 432, Partner 366, Retail 238","Saving without a query answers 422 with missing [\"query\"]"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"The warehouse is deterministic and read-only: the same question always returns the same numbers and no endpoint modifies table data."}],"glossary":[{"term":"Question","meaning":"A saved query (builder form or SQL) with a display type."},{"term":"Truncated","meaning":"True when the result had more rows than were returned."}],"outOfScope":["Question builder and chart user interface","Dashboards","Writes to the warehouse"]},{"ns":"esign","title":"Quillmark eSign","version":"7.0","summary":"Quillmark eSign is a control panel for sending documents for electronic signature. A sender designs a workflow (recipients, document templates, placed fields), launches it as a transaction, and each recipient completes a signing ceremony through a personal link. A Mouse Lab page exercises pointer, scroll, hover and drag interactions.","problem":"Senders need to prepare multi-party signing packets accurately, with fields exactly where they were placed, and signers need a guided, tamper-resistant ceremony with a clear audit trail.","personas":[{"name":"Sender (agent / administrator)","need":"Build a workflow, place fields on pages, send it and track progress, decline and void outcomes."},{"name":"Signer","need":"Open a personal link, consent, adopt a signature, fill required fields and finish with confidence."},{"name":"Approver","need":"Sign only after earlier signers in the routing order have signed."}],"goals":["A field is stored exactly where the sender placed it, in page point coordinates, at any zoom level.","A workflow cannot be sent until every problem is fixed, and all problems are reported at once.","Signing is consent-gated, ordered, single-use and fully audited."],"features":[{"id":"AUTH","title":"Sign-in and session","summary":"Cookie-based sign-in for the control panel with protected routes.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"Anonymous visits to any control-panel page redirect to the sign-in page with the original path preserved; valid credentials open the dashboard.","accept":["Visiting the dashboard anonymously redirects to the sign-in page with ?next=/cp/dashboard and heading \"Sign in to the eSign Control Panel\"","After sign-in the dashboard heading is \"eSign Control Panel\" with \"Welcome back, <name>.\"","The user menu shows the email and role and a \"Sign Out\" entry"]},{"id":"FR-AUTH-02","weight":"Must","text":"Failed sign-ins show distinct messages for missing and wrong credentials and do not leave the sign-in page.","accept":["Empty fields: \"Enter your username and password.\"","Wrong credentials: \"Invalid username or password.\""]},{"id":"FR-AUTH-03","weight":"Should","text":"After sign-in the user lands on the page originally requested; signing out protects the pages again.","accept":["A deep link to Transactions returns to Transactions after sign-in","After Sign Out the same link redirects to sign-in again"]}]},{"id":"DSH","title":"Dashboard","summary":"Counters, recent activity and shortcuts to start a workflow.","reqs":[{"id":"FR-DSH-01","weight":"Must","text":"The dashboard shows five counters (Drafts, Out for Signature, Completed, Declined, Voided) that equal the number of workflows in each status, plus recent activity.","accept":["A fresh session shows Drafts 1, Out for Signature 1, Completed 1, Declined 0, Voided 0","Recent activity lists \"Vendor agreement — Okafor\" (Draft), \"Beneficiary change — Tanaka\" (Out for Signature) and \"Brokerage onboarding — Rivera\" (Completed)","Clicking the Out for Signature counter opens Transactions filtered to that status"]},{"id":"FR-DSH-02","weight":"Should","text":"\"Start a workflow\" offers four types, each with a description; choosing one creates a draft and opens its setup.","accept":["Types: Send for Signature, Sign & Send, In-Person Signing, Sign Only","Choosing Send for Signature opens \"Workflow Setup WF-0004\" with one empty Signer row and the Drafts counter becomes 2"]}]},{"id":"WFL","title":"Workflows","summary":"List, create, edit lock and delete workflows.","reqs":[{"id":"FR-WFL-01","weight":"Should","text":"The Workflows table lists every workflow and supports text search, status filter and sorting by column.","accept":["A fresh session lists 3 rows; the draft offers Edit and Delete, the others View only","Searching \"tanaka\" leaves only \"Beneficiary change — Tanaka\"","A filter with no matches shows \"No workflows match your filters.\"","Clicking the Name header twice sorts ascending then descending and flips the arrow"]},{"id":"FR-WFL-02","weight":"Could","text":"\"+ New Workflow\" replaces itself with a type chooser \"What would you like to do?\" that can be closed; the Workflow Designer menu entry opens it directly.","accept":["The close (×) control restores the button","Workflow Designer > New Workflow opens the page with the chooser already open"]},{"id":"FR-WFL-03","weight":"Should","text":"Only drafts can be deleted, and only after confirmation.","accept":["Dialog \"Delete workflow?\" states the workflow and its task placements will be permanently removed","Cancel keeps the row; confirming removes it and lowers the Drafts counter"]},{"id":"FR-WFL-04","weight":"Must","text":"Workflows that are no longer drafts are locked: they cannot be edited or deleted in the UI or through the API.","accept":["Opening a sent workflow redirects to its transaction details","Editing a sent workflow returns 409 \"Workflow is Out for Signature; only drafts can be edited\"","Deleting one returns 409 \"Only drafts can be deleted — void the transaction instead\"","An unknown workflow returns 404 \"Workflow not found\""]},{"id":"FR-WFL-05","weight":"Could","text":"Sign & Send and Sign Only workflows start with the signed-in user as the first signer; unknown workflow types are rejected.","accept":["Sign Only: recipient 1 is the signed-in user with role Signer","Sign & Send: recipient 1 is the signed-in user followed by an empty recipient 2","Creating a workflow with an unknown type returns 422 \"type must be one of send, sign-send, in-person, self\""]}]},{"id":"REC","title":"Recipients","summary":"Who signs, in which order.","reqs":[{"id":"FR-REC-01","weight":"Should","text":"Recipients can be added, reordered, given a role and removed, and picking a known contact fills the email.","accept":["Choosing contact \"Mei Tanaka\" fills mei.tanaka@example.com","Moving recipient 2 up swaps the order and shows an \"Unsaved changes\" badge","Save shows \"Workflow saved.\" and the order survives a reload"]},{"id":"FR-REC-02","weight":"Could","text":"Saving a recipient with a malformed email is refused by the server and the draft stays unsaved.","accept":["Message \"Recipient 1: “not-an-email” is not a valid email address\""]}]},{"id":"DOC","title":"Document templates","summary":"Attach PDFs from a searchable template library.","reqs":[{"id":"FR-DOC-01","weight":"Must","text":"Choosing a template in the searchable dropdown attaches it; the document processes for about 3 seconds before it can be tagged.","accept":["Typing \"brokerage\" lists 5 options (BrokerageAccountApp, BrokerageAmendment, BrokerageAuthorization, BrokerageDisclosure, BrokerageWaiver, each .pdf)","Picking one shows \"Processing: <file>\", a Documents row with 4 pages and status Processing","Tag Document does nothing while Processing and opens the Tasks Editor once the status is Ready"]},{"id":"FR-DOC-02","weight":"Could","text":"The dropdown is keyboard operable and shows a no-results tip.","accept":["A search with no match shows \"No results found...\"","Typing, ArrowDown and Enter picks the highlighted option"]},{"id":"FR-DOC-03","weight":"Should","text":"A workflow holds at most 5 distinct documents; removing a document removes its tasks.","accept":["Attaching the same template twice alerts \"<file> is already attached\"","After 5 documents the dropdown is disabled; a 6th via the API returns 422 \"A workflow can hold at most 5 documents\"","An unknown template returns 422 \"templateId does not match a document template\""]}]},{"id":"TSK","title":"Tasks editor","summary":"Place and edit signing fields on rendered pages.","reqs":[{"id":"FR-TSK-01","weight":"Must","text":"Dragging a task type from the list onto a page places it where the pointer is released, in PDF points.","accept":["The editor shows 4 pages of 612×792 pt and 10 task types","A Signature marker is 180×36 and appears under the drop point; its Left / Top match the drop point, not the page centre","An announcement \"Signature placed on page 1 at (…) via drag-and-drop\" is made"]},{"id":"FR-TSK-02","weight":"Should","text":"Double-click or Enter on a task type places it centred on the page in view.","accept":["Initials (64×30) lands at Left / Top 274, 381 on page 1","Date Signed placed by keyboard lands at 251, 385"]},{"id":"FR-TSK-03","weight":"Should","text":"Placed markers can be moved, resized (minimum 12×12) and nudged with arrow keys, always staying within the page, and Save keeps their new position and size.","accept":["Moving right by 100 px at 100% zoom raises Left by about 100 and keeps Top","ArrowLeft stops at Left 0; dragging past the right edge clamps so Left + width ≤ 612","Save shows \"Saved 1 task(s)\" and a reload shows the position the marker had when saved"]},{"id":"FR-TSK-04","weight":"Should","text":"The task properties dialog validates numeric input against the page bounds.","accept":["Left 500 with width 180: \"The task must fit inside the page (612 × 792 pt).\"","Left \"abc\": \"left must be a number\"","Left 100, Top 600: \"Task updated\" and the marker moves"]},{"id":"FR-TSK-05","weight":"Could","text":"Zoom changes how large pages and markers are drawn but never their stored point coordinates.","accept":["Zooming to 150% leaves Left / Top unchanged","A task dropped at 150% is stored as screen offset ÷ 1.5","Fit width shows a non-preset percentage"]},{"id":"FR-TSK-06","weight":"Should","text":"The tasks API validates processing state, task type, page, bounds and recipient.","accept":["Still processing: 409 \"<file> is still processing\"","Unknown type: 422 \"Task 1 (Stamp): unknown task type. Allowed: Signature, Initials, Date Signed, Full Name, Email, Title, Company, Text, Checkbox, Attachment\"","Page 9: 422 \"Task 1 (Signature): page must be between 1 and 4\"","Out of bounds: 422 \"Task 1 (Signature): placement is outside the page (page is 612×792 pt)\"","Unknown recipient: 422 \"Task 1 (Signature): recipient 3 does not exist\"","A valid set is saved and echoed with 200"]}]},{"id":"SND","title":"Send for signature","summary":"Validation and launch of a workflow as a transaction.","reqs":[{"id":"FR-SND-01","weight":"Must","text":"Send now is refused while the workflow has problems, and every problem is listed at once.","accept":["An empty draft lists: Enter a workflow title; Recipient 1: full name is required; Recipient 1: a valid email is required; Recipient 1 has no Signature task; Add at least one document","With documents still processing the list includes \"Wait for documents to finish processing\"","A recipient without a Signature task shows \"<name> has no Signature task\""]},{"id":"FR-SND-02","weight":"Must","text":"A complete workflow launches a transaction with a signing link per signer, and cannot be launched twice.","accept":["Message \"Congratulations! You have successfully launched the Workflow to start your Transaction\" and \"Transaction TX-1003 is now out for signature.\"","Each signer gets an \"Open signing link\" to /esign/sign/<token>","The transaction drawer shows the signer Pending and audit entries Created and Sent","Sending again returns 409 \"Workflow already Out for Signature\""]}]},{"id":"SGN","title":"Signing ceremony","summary":"The signer’s consent, signature, field entry and finish.","reqs":[{"id":"FR-SGN-01","weight":"Must","text":"A signer opens the link, consents, adopts a signature, completes required fields and finishes; a finished link cannot be reused.","accept":["Intro shows the workflow title, \"From <sender> · for <name> (<role>)\" and \"<n> document(s), <m> field(s) for you to complete.\"; Continue is disabled until \"I agree to use electronic records and signatures.\" is ticked","Document view shows \"0 of 2 required\" rising to \"2 of 2 required\" as fields are completed; Date inserts today as M/D/YYYY","Finish shows \"All done!\" — \"All parties have signed. A completed copy will be emailed to everyone.\"","Reopening the link shows \"Signed\" — \"You have already signed this document.\""]},{"id":"FR-SGN-02","weight":"Should","text":"Finish is enabled only after the signer has scrolled to the end of the document.","accept":["Disabled Finish shows the hint and tooltip \"Scroll to the end of the document to enable Finish\"","At the end the hint reads \"Ready to finish\""]},{"id":"FR-SGN-03","weight":"Could","text":"\"Next ▸\" scrolls to the next unfilled required field and is disabled when none remain."},{"id":"FR-SGN-04","weight":"Should","text":"The Adopt Your Signature dialog supports select-style, draw and upload and validates each before adoption.","accept":["Empty pad: \"Draw your signature in the box.\"","Non-image upload: \"Upload a PNG, JPG, GIF or SVG image.\"","Image over 1 MB: \"The image must be smaller than 1 MB.\"","Empty name in select-style: \"Type your full name.\""]},{"id":"FR-SGN-05","weight":"Must","text":"The completion endpoint enforces consent, a signature, all required fields and single use, independent of the UI.","accept":["No consent: 422 \"You must agree to use electronic records and signatures\"","No signature: 422 \"Adopt a signature first (draw, type or upload)\"","Missing fields: 422 \"<n> required field(s) still need your input\" with the missing task ids","Empty drawn data: 422 \"The drawn signature is empty — draw your signature in the box\"","Success returns 200 with status Completed; repeating returns 409 \"You have already signed\"","An unknown token returns 404 \"This signing link is invalid or has expired\""]},{"id":"FR-SGN-06","weight":"Must","text":"Signers sign in routing order: a later signer sees a waiting page until earlier signers finish.","accept":["Early signer: \"Not your turn yet\" — \"Waiting for Mei Tanaka to sign first\"","After Mei finishes: \"Thank you, Mei Tanaka. The document has been routed to Ola Nordmann.\" and Ola can then open the ceremony","The transaction then shows Mei Signed with a time, Ola Pending, progress \"1 / 2 signed\""]},{"id":"FR-SGN-07","weight":"Should","text":"A signer can decline with a reason, which sets the transaction to Declined.","accept":["An empty reason shows \"Tell the sender why you are declining.\"","Confirmation \"Declined\" — \"You declined to sign. The sender has been notified.\"","The dashboard moves the workflow from Out for Signature to Declined"]}]},{"id":"TRX","title":"Transactions","summary":"Track and void sent workflows.","reqs":[{"id":"FR-TRX-01","weight":"Should","text":"Transactions lists every sent workflow (never drafts) with progress, searchable, and a details drawer with recipients, documents and audit trail.","accept":["A fresh session lists TX-1002 (0 / 2 signed, \"Mei Tanaka → Ola Nordmann\") and TX-1001 (Completed)","Searching \"nordmann\" leaves only TX-1002","The drawer shows an \"Open signing link\" with Copy (briefly \"Copied!\") only for the active signer; waiting signers have no link","Closing the drawer removes ?open from the address"]},{"id":"FR-TRX-02","weight":"Should","text":"Voiding a transaction needs a reason of at least 5 characters and invalidates its signing links; completed transactions cannot be voided.","accept":["A short reason: \"Give a reason of at least 5 characters.\"","Voided shows \"Voided: <reason>\" and no Void button","A voided signing link shows \"Transaction voided\" — \"This transaction was voided by the sender\"","Voiding a Completed workflow returns 409 \"Cannot void a Completed workflow\""]}]},{"id":"LAB","title":"Mouse Lab","summary":"A page of pointer, scroll, hover and drag exercises.","reqs":[{"id":"FR-LAB-01","weight":"Could","text":"Mouse Lab shows nine numbered sections and an event log; the \"I have read everything\" button is enabled only after its inner scroll area is scrolled to the bottom, and the infinite feed loads 20 envelopes at a time up to 200.","accept":["Reaching the end shows \"No more envelopes\""]},{"id":"FR-LAB-02","weight":"Could","text":"The kanban board (HTML5 drag) and the sortable list (pointer drag) apply moves and keep them for the session.","accept":["Dragging \"Verify ID\" to Done gives To do 2, Done 2","Dragging \"Alpha\" below \"Charlie\" shows \"Order: Bravo, Charlie, Alpha, Delta, Echo, Foxtrot\"","Both survive a reload and are returned by the lab state API"]},{"id":"FR-LAB-03","weight":"Could","text":"Free drag, long-press (1 s hold to confirm), single/double/middle/right clicks and inline rename are recognised and logged.","accept":["Dropping the box in the zone shows \"inside the drop zone ✔\"","Holding 1 s shows \"Confirmed ✔\"; releasing early resets the fill","Right click opens no browser context menu"]},{"id":"FR-LAB-04","weight":"Could","text":"Hover-only controls, canvas pointer tracking, the movable dialog and Ctrl+wheel zoom work.","accept":["Hovering \"@jordan.rivera\" shows a card \"Jordan Rivera — Account owner · 4 envelopes\"","Row actions Edit and Archive appear only on the hovered row","Canvas shows \"x … · y …\" and \"Pointer outside\" when left","Ctrl+wheel changes zoom; Reset returns 100%"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Each browser session sees its own copy of the data; workflows created in one session are invisible to another.","accept":["A second session sees only the 3 seeded workflows and Drafts 1","Opening another session’s workflow shows \"This workflow does not exist (or belongs to another session).\""]},{"id":"NFR-02","weight":"Could","text":"On a 375 px wide viewport the navigation collapses behind a \"Toggle navigation\" button, closes after navigating, and tables stack their rows with labels."}],"glossary":[{"term":"Workflow","meaning":"A draft package of recipients, documents and placed tasks; becomes a transaction when sent."},{"term":"Transaction","meaning":"A sent workflow with per-recipient progress and an audit trail."},{"term":"Task","meaning":"A field placed on a page (Signature, Initials, Date Signed, Full Name, Email, Title, Company, Text, Checkbox, Attachment)."},{"term":"Point","meaning":"PDF unit; pages are 612×792 pt and stored coordinates are in points regardless of zoom."}],"outOfScope":["Real PDF rendering (pages are generated images)","Email delivery","Legal validity of signatures","The Document Templates menu (coming soon)"]},{"ns":"hrm","title":"Tangerine HRM","version":"1.1","summary":"Tangerine HRM is an HR suite with an employee self-service (ESS) role and an HR admin role. It covers sign-in, the employee master list (PIM) with Add Employee, a company Directory, My Info, leave application and listing, a weekly timesheet, claim requests and a Buzz newsfeed. Screens follow the classic /web/index.php/<module>/<screen> URL layout and confirm writes with toasts.","problem":"Employees and HR staff need everyday HR tasks to work the same way every time: validation must be inline and specific, and anything confirmed with a success message must really be saved and visible afterwards.","personas":[{"name":"Employee (ESS)","need":"Apply for leave, check personal details, view the timesheet and read the newsfeed."},{"name":"HR admin","need":"Add employees with unique ids and find any employee quickly."}],"goals":["A \"Successfully Saved\" toast always means the data was stored and can be found again.","Employee ids are unique; searching by id returns exactly that employee.","Counts shown to the user equal the records actually listed.","Overlapping leave cannot be submitted."],"features":[{"id":"AUT","title":"Login and logout","summary":"Classic login page with client-side and server-side validation.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"A valid user signs in from the Login page and lands on the Dashboard with their name in the header.","accept":["Login page shows Username, Password, Login and a \"Forgot your password?\" link, both fields empty, password masked","URL contains \"/dashboard/index\"; header shows \"Jacob Palmer\" for the seeded employee"]},{"id":"FR-AUT-02","weight":"Should","text":"Logout from the user menu ends the session; protected pages then redirect to Login.","accept":["Menu: About, Support, Change Password, Logout","After Logout the URL contains \"/auth/login\", fields are empty; opening the Dashboard URL returns to Login"]},{"id":"FR-AUT-03","weight":"Must","text":"An empty login is blocked client-side.","accept":["\"Required\" under Username and under Password; URL stays on /auth/login"]},{"id":"FR-AUT-04","weight":"Must","text":"A wrong password is refused with a generic message that does not reveal which part was wrong.","accept":["Alert \"Invalid credentials\"; still on the Login page"]},{"id":"FR-AUT-05","weight":"Should","text":"The session is kept in the browser: after visiting an unrelated page on another path and returning, the user is still signed in.","accept":["Returning to My Info shows Personal Details without a new login"]}]},{"id":"PIM","title":"PIM employee records","summary":"Employee list, search and Add Employee (admin).","reqs":[{"id":"FR-PIM-01","weight":"Must","text":"An admin can add an employee with first, middle and last name and an Employee Id of at most 10 characters; the form pre-fills a generated id.","accept":["\"Successfully Saved\" toast, then the new employee's Personal Details opens","The employee appears in Employee List when searched by name, under \"(N) Records Found\""]},{"id":"FR-PIM-02","weight":"Must","text":"An Employee Id that is already in use is refused inline and no record is created.","accept":["\"Employee Id already exists\" under the field; no toast; stays on Add Employee"]},{"id":"FR-PIM-03","weight":"Must","text":"Employee List search by Employee Id returns exactly the matching employee.","accept":["Id 0422: \"(1) Records Found\" with Jacob Palmer only"]}]},{"id":"DIR","title":"Directory","summary":"Cards for every active employee.","reqs":[{"id":"FR-DIR-01","weight":"Should","text":"The Directory lists every active employee as a card; the count is unchanged by Search without filters and returns after Reset.","accept":["\"(46) Records Found\"","Job Title \"QA Engineer\" narrows the cards and count; Reset restores 46","Footer reads \"Tangerine HRM OS 5.7\""]},{"id":"FR-DIR-02","weight":"Must","text":"The \"Records Found\" count equals the number of cards and excludes deleted employees.","accept":["46 cards for \"(46) Records Found\""]}]},{"id":"MYI","title":"My Info","summary":"Employee self-service profile.","reqs":[{"id":"FR-MYI-01","weight":"Should","text":"My Info has ten tabs, each opening its own heading.","accept":["Personal Details, Contact Details, Emergency Contacts, Dependents, Immigration, Job, Salary, Report-to, Qualifications, Memberships","Headings \"Contact Details\", \"Emergency Contacts\", \"Dependents\", \"Job Details\", \"Qualifications\", \"Memberships\"; record tabs show an \"Assigned ...\" list"]},{"id":"FR-MYI-02","weight":"Should","text":"Personal Details shows the seeded master data and the Employee Id is read-only.","accept":["Jacob Palmer; Employee Id 0422 disabled","Nationality Singaporean, Marital Status Single, Date of Birth 1993-05-23, Gender Male","Save button and Attachments section with \"+ Add\""]}]},{"id":"LEV","title":"Leave","summary":"Apply for leave and review requests.","reqs":[{"id":"FR-LEV-01","weight":"Must","text":"An employee can apply for leave and the request is stored as Pending Approval and listed in My Leave List.","accept":["Selecting Annual Leave shows the Leave Balance, e.g. \"9.00 Day(s)\"; To Date mirrors From Date","\"Successfully Saved\" toast","My Leave lists the date with status \"Pending Approval (1.00)\""]},{"id":"FR-LEV-02","weight":"Should","text":"A request that overlaps an existing pending request is refused and the overlap is shown.","accept":["Toast \"Failed to Submit\" and panel \"Overlapping Leave Request Found\" listing the pending \"Moving house\" request"]},{"id":"FR-LEV-03","weight":"Should","text":"My Leave List has this calendar year pre-filled, default status chips and the standard columns; clearing all statuses is rejected.","accept":["Status chips Pending Approval, Scheduled, Taken; Leave Type \"-- Select --\"","Columns Date, Employee Name, Leave Type, Leave Balance (Days), Number of Days, Status, Comments, Actions; no Cancelled row","No chips: \"Required\" under the status filter"]}]},{"id":"TIM","title":"Timesheet","summary":"Weekly timesheet.","reqs":[{"id":"FR-TIM-01","weight":"Could","text":"My Timesheet shows the current Monday-Sunday week with a grid and submission status; the period arrows move one whole week.","accept":["Columns Project, Activity, Mon..Sun with dates, Total","Current week \"Status: Not Submitted\" with Edit","Two weeks back: \"Status: Submitted\", total 37.50 hours"]}]},{"id":"CLM","title":"Claims","summary":"Claim request form.","reqs":[{"id":"FR-CLM-01","weight":"Could","text":"The Create Claim Request form shows its required fields and Cancel returns to My Claims without saving.","accept":["Event and Currency dropdowns at \"-- Select --\", Remarks, \"* Required\", Cancel, Create","After Cancel: \"(1) Records Found\""]}]},{"id":"BUZ","title":"Buzz newsfeed","summary":"Posts with sorting and anniversaries.","reqs":[{"id":"FR-BUZ-01","weight":"Could","text":"The newsfeed lists posts, offers Most Recent, Most Liked and Most Commented sorting and shows Upcoming Anniversaries.","accept":["Most Liked: first post is the volunteering-day post (31 Likes)","Most Recent: selected, newest first"]}]}],"nfr":[],"glossary":[{"term":"ESS","meaning":"Employee self-service: a regular employee user."},{"term":"PIM","meaning":"Personnel information management: the employee master list."}],"outOfScope":["Leave approval by a supervisor","Payroll and salary maintenance","Recruitment and Performance modules","File upload storage"]},{"ns":"misc","title":"Quirks Lab","version":"9.1","summary":"Quirks Lab is a practice site of tricky web UI patterns: sign-in and widgets inside open, closed and nested shadow roots, nested same-origin iframes, canvas-only targets and a \"dynamic\" room with hover menus, late content, rotating identifiers, native dialogs, drag-to-sort and a blocking promo overlay. Each pattern has a visible, checkable outcome.","problem":"Interface automation and manual testers need a stable place to practise elements that defeat naive locators, with an unambiguous on-screen result for every interaction.","personas":[{"name":"Automation engineer","need":"Operate shadow-DOM, iframe, canvas and timing-sensitive controls and confirm the outcome where it really surfaces."},{"name":"Manual tester","need":"Exercise each pattern by hand and compare against exact messages."}],"goals":["Every interaction produces an exact, observable message or state change.","Outcomes that cross a boundary (frame to page, element to server) are really delivered, not just claimed locally.","Values entered by the user are stored and echoed exactly as entered."],"features":[{"id":"SHD","title":"Shadow DOM room: sign-in and vault","summary":"Sign-in card inside an open shadow root and a vault inside a closed shadow root.","reqs":[{"id":"FR-SHD-01","weight":"Must","text":"The sign-in card inside the open shadow root accepts the demo user and signs them in.","accept":["Initial header \"Not signed in\"","After a valid sign-in the card reads \"Signed in as Tess Tester\" and the header shows \"Tess Tester\"","The password field is masked"]},{"id":"FR-SHD-02","weight":"Should","text":"Invalid sign-in attempts are rejected with a message rendered inside the card.","accept":["Both fields empty: \"Username and password are required\"","Wrong password: \"Invalid credentials\" and the header still says \"Not signed in\""]},{"id":"FR-SHD-03","weight":"Should","text":"The Vault (closed shadow root) reveals its code only to a signed-in user.","accept":["Signed out, \"Reveal code\": \"Sign in first to reveal the code\"","Signed in, \"Reveal code\": shows \"QX-7319-ALPHA\" and the button becomes \"Hide code\"","\"Hide code\" masks the code again as dots"]}]},{"id":"WID","title":"Shadow DOM room: widgets","summary":"Nested counter, slotted tabs and a div-based combobox inside shadow roots.","reqs":[{"id":"FR-WID-01","weight":"Should","text":"The counter nested three shadow roots deep (panel, card, counter) steps by exactly 1 per click and clamps between 0 and 10.","accept":["Starts at 0 with \"-\" disabled","Three clicks on \"+\" read 3; one click on \"-\" then reads 2","At 10 \"+\" is disabled"]},{"id":"FR-WID-02","weight":"Could","text":"Slotted tabs switch their light-DOM panels and the shadow combobox selects a value.","accept":["\"Specs\" shows \"Build channel: stable · Shadow roots: open, closed, nested.\"","\"Reviews\" shows a quote ending \"- 5 stars\" and hides the Specs text","Choosing \"Team\" in the combobox shows trigger \"Plan: Team\" and \"Selected plan: Team\""]}]},{"id":"FBK","title":"Feedback with a form-associated rating","summary":"A star-rating custom element that takes part in a native form and is stored by the server.","reqs":[{"id":"FR-FBK-01","weight":"Must","text":"Submitting feedback requires a rating of 1 to 5 stars.","accept":["Submit with no stars: \"Pick 1 to 5 stars\""]},{"id":"FR-FBK-02","weight":"Must","text":"The rating chosen in the custom element is the rating the server stores and echoes.","accept":["Name \"Ada\", 4 stars, comment: \"Saved FB-NNNN · 4/5 stars from Ada\"","Five stars are echoed as \"5/5 stars\"; a rating is never recorded as 0 when stars were chosen"]},{"id":"FR-FBK-03","weight":"Should","text":"Reset clears name, comment and stars and removes the result line."}]},{"id":"FRM","title":"Frames room","summary":"Inline srcdoc iframe and three-level nested same-origin iframes that report to the top page.","reqs":[{"id":"FR-FRM-01","weight":"Should","text":"The Greeter inside the srcdoc iframe greets the typed name and validates empty input.","accept":["Empty name: \"Please type a name\"","Name \"Lin\": \"Hello, Lin!\" inside the frame"]},{"id":"FR-FRM-02","weight":"Must","text":"A delivery choice in the outer frame and a confirmation in the inner frame are delivered to the top page.","accept":["Confirm is disabled until \"I agree to the delivery terms\" is ticked","After Confirm the inner frame says \"Sent to the page\"","The top page status then reads \"Confirmed by inner frame · Express shipping\" for the Express choice (Standard shipping by default)","The inner frame never claims delivery unless the top page was actually updated"]}]},{"id":"CNV","title":"Canvas room","summary":"Shape targets and a signature pad drawn on canvases without DOM nodes.","reqs":[{"id":"FR-CNV-01","weight":"Could","text":"Shape targets must be clicked in the requested order: green circle, red triangle, purple square.","accept":["Prompt \"Target 1 of 3: click the green circle\"","A wrong shape shows \"Missed (clicked the blue square)\"","Correct hit shows \"Hit: green circle\" and advances the prompt","After all three: \"All 3 targets hit\""]},{"id":"FR-CNV-02","weight":"Could","text":"The signature pad counts each pointer stroke; Save is enabled only when at least one stroke exists.","accept":["Initially \"Strokes: 0\" and Save disabled","Two strokes: \"Strokes: 2\", Save enabled, Save shows \"Signature saved (2 strokes)\"","Clear empties the pad, \"Strokes: 0\", Save disabled"]}]},{"id":"DYN","title":"Dynamic room","summary":"Hover menu, late content, rotating ids, native dialogs, drag-sort, gated controls and popup.","reqs":[{"id":"FR-DYN-01","weight":"Should","text":"About 1 s after the room opens a \"Join the Lab Pro beta\" overlay blocks the page until \"No thanks\" is clicked; it appears once per browser tab."},{"id":"FR-DYN-02","weight":"Should","text":"File menu opens on hover with a delayed two-level submenu.","accept":["Hovering \"Export\" shows CSV and PDF; clicking CSV shows \"Exported report.csv\""]},{"id":"FR-DYN-03","weight":"Should","text":"Generate report renders its result after 1.5 to 3 s; Publish shows a toast for about 2.5 s.","accept":["While running: spinner \"Generating...\" and the button disabled","Result \"Report RPT-NNNN ready · 128 rows\"","Toast \"Changes published\" disappears after about 2.5 s"]},{"id":"FR-DYN-04","weight":"Could","text":"The \"Save draft\" button changes its id and class every 2 s but always counts each click.","accept":["Three clicks show \"Drafts saved: 3\""]},{"id":"FR-DYN-05","weight":"Should","text":"Native confirm and prompt dialogs drive the item actions.","accept":["Delete + Cancel: \"Delete cancelled\"; Delete + OK: \"Item deleted\"","Rename with \"Invoice 42 (final)\": \"Renamed to Invoice 42 (final)\""]},{"id":"FR-DYN-06","weight":"Should","text":"Drag-and-drop reorders the step list and Check order evaluates it.","accept":["Initial order is 3, 1, 5, 2, 4: \"Order wrong: 3, 1, 5, 2, 4\"","After dragging into 1..5: \"Order correct\""]},{"id":"FR-DYN-07","weight":"Could","text":"Consent-gated Continue, a rich-text note and a help popup work.","accept":["Continue is disabled until \"I have read the lab rules\" is ticked, then \"Continued after consent\"","Bold text typed as \"agenda\" saves as \"Saved 21 characters (with bold text)\"","\"Open help in a new window\" opens a window titled \"Help center\" with a working \"Close window\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Interactions inside shadow roots and frames report their result to the user and to the top page truthfully; no control shows success that did not happen."},{"id":"NFR-02","weight":"Should","text":"Feedback is scoped to the browser session and identifiers have the form FB-NNNN."}],"glossary":[{"term":"Open shadow root","meaning":"A shadow tree reachable through element.shadowRoot."},{"term":"Closed shadow root","meaning":"A shadow tree not reachable from script; read it from the screen."}],"outOfScope":["Cross-origin iframes","File download contents","Mobile touch gestures beyond pointer events"]},{"ns":"helpdesk","title":"Relay Desk","version":"3.3","summary":"Relay Desk is a customer-service platform. Agents work tickets in a console with views, first-reply SLA timers, a ticket workspace (public reply vs internal note, macros, properties), bulk actions, a live chat queue, a knowledge base and reports. Customers use a public help center to file requests, look up their status and search articles.","problem":"Support teams need to answer customers quickly without ever exposing internal discussion, losing a teammate’s reply, or silently breaking an SLA; customers need a simple way to ask for help and follow up.","personas":[{"name":"Support agent","need":"Work a queue of tickets, reply, add internal notes, use macros and chat, and see which tickets are about to breach."},{"name":"Customer","need":"Submit a request, receive a request number and check its status without seeing internal notes."},{"name":"Team lead","need":"Trust that reports, SLA counts and merges reflect what actually happened."}],"goals":["Internal notes are never visible to customers.","SLA state always matches the ticket’s priority, age and status.","A reply is never sent over a teammate’s newer reply without the agent seeing it first, and destructive actions need explicit confirmation."],"features":[{"id":"AUT","title":"Agent sign-in","summary":"Email and password sign-in to the console.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"A valid agent signs in and lands on \"Your unsolved tickets\" with the Views sidebar showing counts.","accept":["URL becomes /helpdesk/agent/views/mine; the header shows the agent name and role","Seven views: Your unsolved tickets, Unassigned tickets, All unsolved tickets, SLA breached, Pending tickets, Recently solved, All tickets (60)"]},{"id":"FR-AUT-02","weight":"Should","text":"Empty and wrong credentials are rejected on the sign-in page.","accept":["Empty form: \"Enter your email and password\"","Wrong password: \"Invalid email or password\"; the page stays on sign-in"]}]},{"id":"LST","title":"Ticket list","summary":"Views with search, filters, sorting and paging.","reqs":[{"id":"FR-LST-01","weight":"Should","text":"The list pages 25 tickets at a time and supports search, status/priority filters and Clear.","accept":["\"All tickets\" shows \"60 tickets\" and the pager \"1–25 of 60\"; after Next twice \"51–60 of 60\" with Next disabled","Search \"refund\" lists only tickets whose subject or tags mention refund, including #1001","A priority filter with no matches shows \"No tickets match this view and \"refund\".\"; Clear restores 60 tickets"]},{"id":"FR-LST-02","weight":"Could","text":"Opening a ticket link while signed out signs the agent in and then opens that ticket; reload keeps the agent on it."}]},{"id":"SLA","title":"SLA timers","summary":"First-reply SLA per ticket.","reqs":[{"id":"FR-SLA-01","weight":"Must","text":"The first-reply deadline is the ticket’s creation time plus its priority target: Urgent 1 h, High 4 h, Normal 8 h, Low 24 h; a ticket past the deadline with no reply is breached and listed in \"SLA breached\".","accept":["#1001 (High, 6 h old, no reply) is in \"SLA breached\" with a red negative badge such as \"−2h 0m\"","The \"SLA breached\" view count matches the number of breached tickets"]},{"id":"FR-SLA-02","weight":"Should","text":"Badges reflect state: running, warning, paused, met.","accept":["#1004 (High, New, 90 min old) shows a neutral badge of about \"2h 30m\"","Pending and On-hold tickets show \"⏸ Paused\"","Solved tickets or tickets with a first reply show \"✓ Met\", never a running timer"]}]},{"id":"WRK","title":"Ticket workspace","summary":"Reply, internal notes, solving rules and properties.","reqs":[{"id":"FR-WRK-01","weight":"Must","text":"A public reply is added to the thread under the agent’s name, keeps the status the agent chose and records the first response.","accept":["The composer defaults to \"Public reply\" with hint \"Reply to Elena Park — the customer will see this\"","Choosing Pending changes the button to \"Submit as Pending\"; after sending: toast \"Ticket #1001 updated · Pending\", reply last in the thread, status pill Pending","\"First reply due\" in Properties shows \"✓ Met\""]},{"id":"FR-WRK-02","weight":"Must","text":"An internal note is visible to agents only and is never shown to the customer.","accept":["The Internal note tab turns the composer yellow with \"Internal note — only agents can see this\"","The note appears in the thread with a yellow \"Internal note\" tag","The customer’s status lookup shows only public messages; no note, including seeded ones, appears"]},{"id":"FR-WRK-03","weight":"Must","text":"A ticket cannot be solved without an assignee.","accept":["Submitting as Solved on an unassigned ticket shows \"Assignee is required to solve a ticket\" and the status stays New","\"Take it\" assigns the agent; solving then succeeds with toast \"Ticket #1002 updated · Solved\" and the ticket leaves \"Unassigned tickets\""]},{"id":"FR-WRK-04","weight":"Should","text":"The Root cause field is shown only for tickets of type Incident, is required to solve one and is cleared when the type changes.","accept":["Type Question: no Root cause field; Type Incident: \"Root cause — required to solve\" appears","Solving an Incident without it shows \"Root cause is required to solve an Incident\" and the status stays New","Switching the type back to Question, saving, then to Incident again shows an empty Root cause"]},{"id":"FR-WRK-05","weight":"Should","text":"Assignee is a searchable combobox listing \"— Unassigned —\" and every agent.","accept":["Typing \"pri\" leaves only Priya Nair; \"zzz\" shows \"No agent matches \"zzz\"\"","Enter selects the highlighted option and closes the list","\"Save properties\" persists the assignee across reload"]},{"id":"FR-WRK-06","weight":"Should","text":"Agents can create a ticket on behalf of a customer; it links to an existing customer record.","accept":["\"Requester email is required\" then \"Subject is required\" when missing","Success: toast \"Ticket #NNNN created\" and the workspace opens with status New","\"Other tickets\" lists the customer’s earlier tickets"]},{"id":"FR-WRK-07","weight":"Could","text":"Unknown ticket ids show a friendly page: \"Ticket not found\" and \"There is no ticket #9999.\""}]},{"id":"SND","title":"Sending and concurrency","summary":"Undo window and protection against overwriting teammates.","reqs":[{"id":"FR-SND-01","weight":"Must","text":"If a teammate has replied since the agent opened the ticket, the agent’s reply is not sent; the conflict is announced and the draft is kept.","accept":["Alert \"Sam Okoye updated this ticket while you were writing. Review the new reply before you submit.\"; Submit is disabled; the draft stays in the composer","\"Review changes\" shows the teammate’s reply and the new status without altering the draft","The second submit succeeds; the thread contains exactly one copy of each reply, the teammate’s first","Presence banners \"👀 Sam Okoye is also viewing this ticket\" and \"✍ Sam Okoye is typing a reply…\" appear on the ticket"]},{"id":"FR-SND-02","weight":"Should","text":"Replies are held for 5 seconds with an Undo; undoing sends nothing and keeps the draft.","accept":["Status bar \"Sending in 5s…\" with \"Undo\"; Submit is disabled","Undo shows \"Sending cancelled — your draft is kept\"; the thread, status and SLA are unchanged after reload"]}]},{"id":"MAC","title":"Macros","summary":"Canned replies that also set properties.","reqs":[{"id":"FR-MAC-01","weight":"Must","text":"Applying \"Refund issued\" fills the reply, sets status Solved and adds the tag refund; submitting applies all three.","accept":["Reply starts \"Hi Elena, we have issued a full refund.\"; button reads \"Submit as Solved\"; hint lists tags: refund","After submit: toast \"Ticket #1001 updated · Solved\", status pill Solved, tag refund present and the ticket is in \"Recently solved\""]},{"id":"FR-MAC-02","weight":"Could","text":"A private macro such as \"Escalate to Tier 2\" switches the composer to an internal note and sets group Technical and the priority it defines; the button reads \"Submit as Open\"."}]},{"id":"BLK","title":"Bulk actions","summary":"Act on several selected tickets.","reqs":[{"id":"FR-BLK-01","weight":"Should","text":"Bulk assign and bulk status update the selected tickets; ineligible tickets are reported and left unchanged.","accept":["Two selected tickets: \"2 tickets assigned to you\"; both leave \"Unassigned tickets\" and the count drops by 2","Setting Solved on an unassigned ticket: \"0 updated · 1 skipped (no assignee): #NNNN\" and the row stays"]}]},{"id":"MRG","title":"Merge tickets","summary":"Destructive merge of a duplicate into another ticket.","reqs":[{"id":"FR-MRG-01","weight":"Must","text":"Merge requires choosing the target, previewing it and typing MERGE; the source is closed and cannot be merged again.","accept":["The preview shows the target ticket and requester; \"Merge tickets\" stays disabled until exactly \"MERGE\" is typed (lowercase does not count)","Result: toast \"#1005 merged into #1001\"; #1001 gains a system note about the merge","The merged ticket is Closed with banner \"This ticket was merged into #1001 and is closed.\"; its \"Merge into another ticket…\" is disabled","Merging a ticket into itself shows \"A ticket cannot be merged into itself\""]},{"id":"FR-MRG-02","weight":"Should","text":"Requesters with similar names are separate customers and are told apart by email.","accept":["#1001 Elena Park and #1005 Elena Parks have different emails and different orders (BZ-48213 vs BZ-48231)"]}]},{"id":"CHT","title":"Live chat","summary":"Queue of waiting visitors and chat-to-ticket conversion.","reqs":[{"id":"FR-CHT-01","weight":"Must","text":"An agent can accept a waiting chat, converse with the visitor and convert the transcript into a ticket when ending the chat.","accept":["The queue shows \"Waiting (3)\" with Visitor 4821 on /orders/BZ-77120","Accept opens \"Chat with Visitor 4821\" with the visitor’s opening message; sending a message shows a typing indicator and then the visitor’s answer","End chat with \"Convert to ticket\" ticked and a subject: toast \"Chat ended · ticket #NNNN created\"; the ticket has channel Chat, the agent as assignee and a transcript note"]}]},{"id":"POR","title":"Help center","summary":"Public request form, status lookup and knowledge base.","reqs":[{"id":"FR-POR-01","weight":"Must","text":"The request form validates input and, once valid, creates a request and returns its number.","accept":["Invalid email: \"Enter a valid email address\" with the field marked invalid","Description shorter than 10 characters: \"Describe the issue in at least 10 characters\"","Success: \"Request #NNNN received\" and \"We emailed a confirmation to <email>.\"","Agents see the request in \"Unassigned tickets\" with status New, the chosen group and tag portal"]},{"id":"FR-POR-02","weight":"Should","text":"Status lookup needs the matching request number and email, and shows only public messages.","accept":["Wrong email: \"No request matches that number and email\"","Right email for #1003: \"#1003 Package arrived damaged\" with status \"Awaiting your reply\""]},{"id":"FR-POR-03","weight":"Could","text":"The knowledge base lists 12 articles grouped Accounts, Billing, Shipping, Technical, is searchable and expands articles inline.","accept":["Search \"password\" gives \"1 article\" — \"Reset your password\" (\"Links expire after 30 minutes.\")","No match: \"No articles match \"zzz\".\""]}]},{"id":"RPT","title":"Reports","summary":"KPIs and distributions from the live ticket set.","reqs":[{"id":"FR-RPT-01","weight":"Could","text":"Reports show Total tickets (60), Unsolved, SLA compliance %, SLA breached and CSAT, plus bars by status, channel and priority; they update after tickets change.","accept":["Solving one unsolved ticket lowers Unsolved by 1 and raises the Solved bar by 1"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"The console is usable at phone width (390 px): navigation moves to a bottom bar, Views open in a drawer and the page does not scroll horizontally."},{"id":"NFR-02","weight":"Must","text":"Customer-facing pages never expose internal notes or other customers’ requests, and the agent console requires sign-in."}],"glossary":[{"term":"Internal note","meaning":"A message only agents can see."},{"term":"Macro","meaning":"A canned reply that can also set status, tags, priority and group."},{"term":"First reply SLA","meaning":"Time allowed for the first public reply, by priority."}],"outOfScope":["Email delivery","Attachments","Real push between agents"]},{"ns":"phones","title":"CellNest","version":"1.0","summary":"CellNest is an online phone store. Shoppers browse a faceted catalog, choose a storage and colour variant with its own price and stock, compare phones, fill a cart with a per-customer limit, apply coupons and trade-in credit, pick an installment plan and pay through a checkout that reprices the cart, books a delivery slot and verifies the card with a one-time code.","problem":"Phones are expensive and come in many near-identical models and variants. Shoppers must be sure the exact variant lands in the cart at the right price, that limits and discounts are applied correctly, and that the amount they approve is the amount charged.","personas":[{"name":"Shopper","need":"Find the exact phone and variant, understand the final price including discounts, trade-in and instalments, and pay safely."},{"name":"Store manager","need":"Limit purchases per customer, apply each coupon rule once and never sell a variant that is out of stock."}],"goals":["The variant chosen is the variant bought, at the variant price.","Every discount, credit, shipping fee and instalment figure is correct to the cent.","An order exists only after the price the buyer approved has been verified by a one-time code.","Catalog filters never show products that do not match."],"features":[{"id":"CAT","title":"Catalog, facets and sort","summary":"Phones are listed in pages with facets, a price range and a sort control.","reqs":[{"id":"FR-CAT-01","weight":"Must","text":"The catalog pages through all phones with \"Show more\" and never shows a card twice.","accept":["The first page reads \"Showing 12 of 29 phones\"; \"Show more\" shows \"Loading…\" then \"Showing 24 of 29 phones\"; again gives \"Showing 29 of 29 phones\" and the button disappears.","Every card name is unique.","The Accessories page shows \"Showing 6 of 6 accessories\" and no phone cards."]},{"id":"FR-CAT-02","weight":"Must","text":"Brand, storage, 5G and condition facets combine with AND, show removable chips, are kept in the URL and clear in one click; a brand filter shows only that brand.","accept":["Brand \"Nova\" shows \"Nova (7)\" and \"Showing 7 of 7 phones\", every name starting with \"Nova\", and a \"Nova\" chip.","Adding Storage 512 GB leaves Nova X12 Pro, Nova X12 Pro Max and Nova C14 Plus; after a reload the URL keeps ?brand=Nova&storage=512.","Condition \"Renewed – Excellent\" on top shows \"No products match these filters\".","\"Clear all filters\" returns to \"Showing 12 of 29 phones\" with no chips."]},{"id":"FR-CAT-03","weight":"Should","text":"The price range filters after a short pause and the Sort-by control orders results; an inverted range is refused.","accept":["Min 300 and Max 500 give chips \"Min $300.00\" and \"Max $500.00\" and only prices in that range.","\"Price: low to high\" starts with Kestrel M10 at $324.00; \"Price: high to low\" starts with Zenith A13 at $499.00, also reachable with ArrowDown and Enter.","Min 600 with Max 400 shows \"Min price must not exceed max price\" and leaves results unchanged."]}]},{"id":"VAR","title":"Variants, stock and limit","summary":"Each phone has storage and colour variants with their own price and stock.","reqs":[{"id":"FR-VAR-01","weight":"Must","text":"Each storage option adds its price to the phone, and a phone cannot be added without a storage choice.","accept":["Nova X12 Pro shows \"From $899.00\" with Storage \"— choose one\".","Add to cart without storage shows \"Choose a storage size\" and the badge stays 0.","128 GB is $899.00, 256 GB is $999.00 (\"or $83.25/mo for 12 months at 0% APR\") and 512 GB is $1,149.00."]},{"id":"FR-VAR-02","weight":"Must","text":"The cart charges the price of the chosen storage and the exact variant chosen, not a similarly named model.","accept":["Glacier, 256 GB, quantity 2 shows \"Added 2 × Nova X12 Pro 256 GB to your cart\", badge 2.","The cart has one line \"Nova X12 Pro\" · \"256 GB · Glacier\", Qty 2, $1,998.00 ($999.00 each), Shipping Free, with no Pro Max, Renewed or Clear Case line.","Glacier 512 GB is a line \"512 GB · Glacier\" at $1,149.00 with Total $1,149.00."]},{"id":"FR-VAR-03","weight":"Should","text":"A sold-out variant cannot be bought, offers a restock alert and remains available in other colours.","accept":["Nova X12 Pro 512 GB in Sunset reads \"Sold out\", shows \"Nova X12 Pro 512 GB in Sunset is sold out.\" and has no Add to cart button.","\"Notify me\" with an email shows \"We'll email <address> when Nova X12 Pro 512 GB in Sunset is back.\"","Switching to Glacier (keyboard ArrowRight/ArrowLeft works) makes 512 GB buyable at $1,149.00."]},{"id":"FR-VAR-04","weight":"Must","text":"A customer may hold at most 3 units of one model across all colours and storages, on the product page, the cart stepper and when moving a saved line back.","accept":["With 2 Graphite 128 GB, adding 2 Glacier 128 GB fails with \"Limit 3 per customer for Nova X12 Pro. You already have 2 in your cart.\"","Adding 1 Glacier succeeds and the badge reads 3; \"+\" on the Graphite line shows \"Limit 3 per customer for Nova X12 Pro\" and keeps quantity 2.","Moving a saved line back to the cart fails with the limit message and the line stays under \"Saved for later\"."]}]},{"id":"CRT","title":"Cart, coupons and shipping","summary":"The cart supports removal with undo, one coupon per order and a free-shipping threshold.","reqs":[{"id":"FR-CRT-01","weight":"Should","text":"Removing a line asks for confirmation in a modal and an Undo restores the same variant and quantity.","accept":["\"Remove\" then \"Keep it\" leaves the line.","\"Remove\" then \"Remove\" shows \"Removed Orbit S9 from your cart\" with \"Undo\" and \"Your cart is empty\".","Undo within 6 seconds restores the Orbit S9 · 256 GB · Cobalt line at $899.00."]},{"id":"FR-CRT-02","weight":"Must","text":"Only one coupon applies per order: CELL10 gives 10% off phones only, a second code replaces the first, a coupon applied twice discounts once, and expired or unknown codes are refused.","accept":["Nova X12 Pro 128 GB ($899.00) and a $15.00 cable: CELL10 shows \"CELL10 applied\", discount −$89.90 (phone only) and Total $824.10.","SAVE50 shows \"SAVE50 replaced CELL10 — one code per order\", discount −$50.00 and Total $864.00.","SPRING25 shows \"SPRING25 expired on 30 Sep\"; BOGUS shows \"\\\"BOGUS\\\" is not a valid code\"; SAVE50 stays applied.","Removing the phone shows SAVE50 as \"—\" with \"Add $485.00 more to use SAVE50\" and Total $20.99.","Applying CELL10 to one Nova X12 Pro 128 GB gives −$89.90 and Total $809.10; applying it again leaves both unchanged."]},{"id":"FR-CRT-03","weight":"Should","text":"The CELL10 discount never exceeds $100.00.","accept":["Nova X12 Pro Max 1 TB, subtotal $1,349.00: CELL10 gives −$100.00 (not $134.90) and Total $1,249.00."]},{"id":"FR-CRT-04","weight":"Should","text":"Orders under $50.00 pay $5.99 shipping with a progress hint; at $50.00 or more shipping is free.","accept":["A $15.00 cable shows \"Add $35.00 for free shipping\", Shipping $5.99 and Total $20.99.","Adding a $19.00 and a $29.00 accessory gives Subtotal $63.00, Shipping Free, Total $63.00 and no hint."]}]},{"id":"TRD","title":"Trade-in","summary":"A six-step wizard quotes credit for an old phone and applies it to the order.","reqs":[{"id":"FR-TRD-01","weight":"Must","text":"The trade-in wizard keeps answers on Back, quotes credit by condition and applies the credit to the cart.","accept":["Next without a device shows \"Choose your phone's brand and model\".","Nova X11 Pro, answers \"Yes\", \"Light scratches\", \"Good — normal wear\", \"No, it is off\" quote \"$323.00 credit\" with quote id TQ-… valid 30 minutes.","Going Back and changing Body to \"Dents, bends or cracks\" quotes \"$247.00 credit\" with earlier answers kept.","\"Apply credit\" shows \"Trade-in credit of $247.00 applied\", a \"Trade-in: Nova X11 Pro (scratched screen)\" line of −$247.00 and Total $652.00 on a $899.00 phone."]},{"id":"FR-TRD-02","weight":"Should","text":"Locked phones and phones that do not power on get a $0 quote that cannot be applied.","accept":["Orbit S8 with device lock on shows \"$0.00 credit\", \"Turn off device lock (Find my phone) before trading in.\" and a disabled \"Apply credit\".","Power \"No\" shows \"Phones that do not power on are recycled for free (no credit).\""]}]},{"id":"CHK","title":"Checkout and payment","summary":"Checkout takes an address, a delivery slot, a payment plan and a verified card payment.","reqs":[{"id":"FR-CHK-01","weight":"Should","text":"A guest is sent to sign-in at checkout and returns to checkout with the same cart; wrong sign-in details are refused.","accept":["Checkout shows \"Sign in to check out. Your cart is kept.\"","A wrong password shows an error alert and stays on Sign in.","After a correct sign-in checkout opens at step 1 with Full name \"Jordan Lee\" and Order summary total $499.00."]},{"id":"FR-CHK-02","weight":"Must","text":"If prices changed since the cart was built, payment shows the change and the buyer must accept the new total before any charge; the order is placed only after the one-time code is confirmed.","accept":["Address step offers five day tabs (no Sunday); the \"17:00 – 21:00\" window can be chosen while the first day 8:00 – 12:00 window shows \"Full\" and cannot be chosen.","Orbit S9 Cobalt 128 GB: \"Pay $799.00\" shows \"Prices changed\": \"Orbit S9: $799.00 → $749.00\", New total $749.00.","\"Pay new total\" opens \"Verify it's you\" for $749.00 on the card ending 4242 with a 1:30 countdown; the code arrives as a notification about 1.5 seconds later and disappears after about 8 seconds.","The correct code shows \"Thank you! Your order CN-2026-NNNNNN has been placed.\", status Processing, Total $749.00 and the chosen delivery window; the cart is empty."]},{"id":"FR-CHK-03","weight":"Must","text":"Three wrong verification codes cancel the payment and no order is created.","accept":["Total $74.00: wrong codes show \"Wrong code. 2 attempts left.\", \"Wrong code. 1 attempt left.\" then \"Too many wrong codes — payment cancelled\" with inputs and Confirm disabled.","\"My orders\" shows no new order."]},{"id":"FR-CHK-04","weight":"Should","text":"Errors found by the server return the buyer to the right step with the message beside the field, and a declined card creates no order.","accept":["Phone \"555\" reopens step 1 with \"Enter a phone number for the courier\".","Expiry 01/24 reopens step 3 with \"Card has expired\".","Card 4000 0000 0000 0002 shows \"Your card was declined. Try another card.\" with no verification modal."]},{"id":"FR-CHK-05","weight":"Should","text":"Instalment plans are offered only for orders of $300.00 or more; 0% plans divide evenly and the 9.9% APR plan costs more in total.","accept":["Nova X12 Pro 256 GB offers: Pay in full $999.00; 6 months · 0% APR $166.50/mo; 12 months · 0% APR $83.25/mo; 24 months · 9.9% APR with a total above $999.00.","12 months makes the pay button read \"Pay $83.25 today (plan 12 mo)\".","A cart holding only a cable offers only \"Pay in full\"."]}]},{"id":"ORD","title":"Orders and compare","summary":"Fresh orders can be cancelled and up to three phones can be compared.","reqs":[{"id":"FR-ORD-01","weight":"Should","text":"A Processing order can be cancelled within 30 minutes with a reason; afterwards the option is gone.","accept":["A $29.00 charger totals $34.99 including $5.99 shipping; the order shows status Processing and \"Cancel order\".","Confirming without a reason shows \"Choose a reason\".","\"Ordered by mistake\" shows \"Order CN-2026-… cancelled. Your refund will appear in 3–5 business days.\", status Cancelled, \"Cancelled: Ordered by mistake\" and no Cancel button."]},{"id":"FR-ORD-02","weight":"Could","text":"Compare holds at most three phones and can hide identical rows.","accept":["A fourth tick shows \"You can compare up to 3 phones. Remove one first.\" and the tray reads \"Compare (3/3)\".","\"Compare now\" shows a table with chip \"Helix 9\" for both Nova phones and \"Quark 4\" for Orbit S9.","\"Show differences only\" hides identical rows and keeps Price from, Display and Battery."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Prices, limits, coupon rules and the verification code are checked by the server, not only by the screen.","accept":["A client request for a lower price or a fourth unit is refused."]},{"id":"NFR-02","weight":"Should","text":"Paged lists load without duplicates even when loading is slow."}],"glossary":[{"term":"Variant","meaning":"A storage and colour combination of one phone model with its own price and stock."},{"term":"Trade-in credit","meaning":"A quoted amount deducted from an order in exchange for an old phone."},{"term":"Verification code","meaning":"A six-digit one-time code that confirms the card payment."}],"outOfScope":["Real payments, shipping or email","Review content","Accessibility audit"]},{"ns":"market","title":"Bazaario","version":"1.0","summary":"Bazaario is a multi-seller online marketplace. Buyers discover products through an infinite-scroll feed and a timed flash sale, search with live suggestions, choose two-tier variations with per-combination stock, fill a cart grouped by shop, apply shop and platform vouchers and coins, pay by one of three methods and follow each per-shop order through its lifecycle.","problem":"Marketplace buyers combine items from several independent shops in one checkout. Discounts, shipping and stock rules differ per shop, so a single wrong rule produces wrong money totals or oversold goods. Buyers need totals, limits and order states that are always correct and explained.","personas":[{"name":"Buyer","need":"Find the right product among lookalikes, buy from several shops at once and see exactly what is charged."},{"name":"Shop owner","need":"Have vouchers, flash-sale limits and shipping thresholds honoured so promotions stay within budget."}],"goals":["Every price, discount, shipping fee and total shown to the buyer is correct to the cent.","Stock and per-buyer limits are enforced by the server, never only by the screen.","One checkout across several shops produces one order per shop whose totals add up to the amount paid.","Discovery, search and cart interactions stay reliable under fast typing and long lists."],"features":[{"id":"DSC","title":"Home feed and flash sale","summary":"The home page shows a flash-sale strip and an infinite-scroll product feed.","reqs":[{"id":"FR-DSC-01","weight":"Must","text":"The Daily Discover feed loads more products as the buyer scrolls to the bottom, without paging buttons, until every product is shown exactly once.","accept":["Initially 10 product cards are shown and there is no \"Next\" or \"Show more\" button.","After the first scroll to the bottom, \"Loading…\" shows briefly and 20 cards are shown.","After the second scroll, 24 cards are shown with the text \"You've reached the end · 24 products\".","No product name appears twice."]},{"id":"FR-DSC-02","weight":"Should","text":"The Flash Sale strip lists the current deals with sale price, struck regular price, a stock indicator and a live countdown.","accept":["Three deals: Wireless Earbuds Pro $14.99 (was $29.99) \"Almost sold out\"; Ceramic Mug Set (4 pcs) $9.99 (was $18.99) \"12 sold\"; Snack Box Mix (24 pcs) $11.99 (was $15.99) \"Only 3 left\".","The \"Ends in\" countdown uses HH:MM:SS, is below 00:20:00 and decreases by about 5 seconds after 5 seconds."]}]},{"id":"SRC","title":"Search, sort and filters","summary":"Buyers search with live suggestions, sort the results and narrow them with filters kept in the URL.","reqs":[{"id":"FR-SRC-01","weight":"Should","text":"Search suggestions always correspond to the latest text typed, even though responses arrive in a different order, and can be chosen by keyboard.","accept":["After typing \"ear\" and quickly replacing it with \"linen\", only Linen suggestions plus \"Shop: Linen & Loom\" are listed; no earbuds suggestion remains.","ArrowDown twice then Enter opens results for \"Linen Drawstring Pants\" with keyword=Linen%20Drawstring%20Pants in the URL.","Choosing \"Shop: Linen & Loom\" opens that shop page, which states \"Free shipping on orders from $50.00 in this shop · otherwise $2.99\"."]},{"id":"FR-SRC-02","weight":"Should","text":"Search results distinguish lookalike products by exact name.","accept":["Searching \"wireless earbuds pro\" returns three lookalikes; only the product named exactly \"Wireless Earbuds Pro\" is the flash-sale item, showing \"FLASH SALE\", $14.99 with $29.99 struck and \"Flash Sale price: limit 2 per buyer.\""]},{"id":"FR-SRC-03","weight":"Should","text":"Results can be ordered by Top Sales and by price using a listbox control.","accept":["For \"linen\", Top Sales gives Crew Socks 5-Pack, Canvas Tote Bag, Linen Bedsheet Set, Linen Drawstring Pants, Linen Shirt Relaxed Fit, and the URL has sort=sales.","\"Price: Low to High\" gives $11.99, $12.99, $34.99, $39.99, $59.99 and the button reads \"Price: Low to High\".","\"Price: High to Low\" starts with Linen Bedsheet Set $59.99 and ends with Crew Socks 5-Pack $11.99."]},{"id":"FR-SRC-04","weight":"Should","text":"Price range, Mall and rating filters combine with AND, are kept in the URL and survive a reload; an invalid range is refused.","accept":["Min 40 with max 20 shows \"Please input valid price range\" and the result list is unchanged.","Min 20 and max 40 on \"linen\" shows \"2 products\" (Linen Shirt Relaxed Fit $34.99, Linen Drawstring Pants $39.99) with min=20&max=40 in the URL; a reload keeps both.","Searching \"home\" with Bazaario Mall and rating \"4 & Up\" returns only Smart Power Strip (4.8); rating 5 returns \"No results found. Try different or more general keywords.\""]}]},{"id":"VAR","title":"Variations, stock and flash limit","summary":"Products have two-tier variations with per-combination price and stock; flash prices have a per-buyer limit.","reqs":[{"id":"FR-VAR-01","weight":"Must","text":"A product with variations cannot be added to the cart until a combination is chosen, sold-out combinations cannot be picked and the price follows the chosen combination.","accept":["Add To Cart with nothing chosen shows \"Please select product variation first\" and the cart badge is unchanged.","On the keyboard product, Switch \"Blue\" shows $74.99 and disables Colour \"White\".","Blue with Black shows \"26 pieces available\"; Red with White shows $69.99 and \"27 pieces available\".","On the earbuds, Colour Mint is disabled (sold out) while Black and White can be chosen."]},{"id":"FR-VAR-02","weight":"Must","text":"The server refuses any add that would take the quantity in the cart above available stock, counting what is already in the cart.","accept":["With 3 Smart Power Strips (all available) in the cart, adding 1 more shows \"You have 3 in your cart. Only 3 available.\" and the cart still holds 3."]},{"id":"FR-VAR-03","weight":"Must","text":"A flash-sale price is limited to 2 units per buyer in total across all variations, in the product page and in the cart stepper.","accept":["Adding 3 Black at once shows \"Flash Sale price is limited to 2 per buyer\" and adds nothing.","Adding 1 Black and 1 White succeeds; one more White shows the same message.","In the cart, two lines at $14.99 are tagged \"Flash Sale · limit 2\"; pressing + keeps the quantity at 1 with the same message; \"Total (2 items): $29.98\"."]}]},{"id":"CRT","title":"Cart by shop","summary":"The cart groups lines by shop, with selection controlling what is bought and totalled.","reqs":[{"id":"FR-CRT-01","weight":"Must","text":"Cart lines are grouped per shop with a shop checkbox, per-line checkboxes and Select All; the total counts only ticked lines, and Check Out requires a selection.","accept":["Mouse Silent Black, Ceramic Mug Set Sage and Canvas Tote Bag show badge 3 and groups Volt Gadget Hub $14.99, Kitty Corner Home $9.99 (flash price), Linen & Loom $12.99; \"Total (3 items): $37.97\".","Unticking Kitty Corner Home gives \"Total (2 items): $27.98\" and unticks Select All (3).","Unticking everything gives \"Total (0 items): $0.00\"; Check Out shows \"You have not selected any items.\" and stays on the cart."]},{"id":"FR-CRT-02","weight":"Should","text":"Changing a line's variation to one already in the cart merges the two lines and sums their quantities.","accept":["White/M ×1 and Navy/M ×2: changing White to Navy leaves one line \"Variations: Navy, M\" ×3 at $104.97 and the badge shows 1.","In the picker, size S is disabled while Navy is chosen (sold out)."]},{"id":"FR-CRT-03","weight":"Should","text":"Removing items always asks for confirmation.","accept":["Pressing − at quantity 1 asks \"Do you want to remove this item?\"; \"No\" keeps the line.","Bulk Delete asks \"Do you want to remove the 2 selected items?\"; \"Yes\" shows \"Your shopping cart is empty\" and \"Go Shopping Now\"."]}]},{"id":"VCH","title":"Shop and platform vouchers","summary":"Shop vouchers are claimed by following a shop; platform vouchers FREESHIP, BIG10 and NEWUSER5 have their own conditions.","reqs":[{"id":"FR-VCH-01","weight":"Must","text":"A shop voucher can be claimed only by a logged-in buyer who follows the shop, and only once.","accept":["A guest pressing Claim is redirected to Log In.","A logged-in buyer who has not followed sees \"Follow the shop to claim this voucher\".","After \"+ Follow\" the button reads \"✓ Following\" with \"48,211 followers\"; Claim then shows \"Claimed\" and cannot be clicked again."]},{"id":"FR-VCH-02","weight":"Must","text":"A claimed shop voucher is unusable until the shop subtotal reaches its minimum spend, and the cart tells the buyer how far free shipping is.","accept":["With one Mouse Silent in the cart the Volt group footer reads \"Add $15.01 more to get free shipping\".","The \"$3.00 off · Min. spend $25.00\" voucher is disabled with \"Spend $10.01 more in this shop\".","At quantity 2 (subtotal $29.98, under the $30.00 free-shipping line) the voucher applies: Shop Voucher Discount −$3.00 and Standard Delivery $1.99."]},{"id":"FR-VCH-03","weight":"Must","text":"Platform vouchers enforce their conditions and explain why they cannot be used; BIG10 needs $40.00 of merchandise; typed codes are validated.","accept":["With merchandise $39.99, BIG10 is disabled with \"Spend $0.01 more\" and NEWUSER5 with \"For new customers only\"; FREESHIP is selectable.","Typing SAVE99 shows \"Invalid voucher code\"; typing NEWUSER5 shows \"NEWUSER5: For new customers only\".","FREESHIP on shipping $2.99 gives Bazaario Voucher Discount −$2.99 and Total Payment $39.99.","With merchandise $51.98, BIG10 gives Shipping Total $0.00, discount −$5.19 and Total Payment $46.79."]}]},{"id":"CHK","title":"Checkout and payment","summary":"Checkout splits one purchase into one order per shop and supports BazaarPay with PIN, cash on delivery and bank transfer.","reqs":[{"id":"FR-CHK-01","weight":"Must","text":"A checkout across several shops applies shop vouchers, the platform voucher and coins, charges shipping per shop, and creates one order per shop whose totals add up to the amount paid.","accept":["Default address shows \"Sam Rivera (312) 555-0187\", \"48 Orchard Lane, Apt 3B, Chicago, IL 60614\".","Two Mouse Silent, one Ceramic Mug Set and one Canvas Tote Bag with the $3.00 shop voucher, FREESHIP and coins: coins row \"Redeem 499 coins [−$4.99]\", Merchandise Subtotal $52.96, Shipping Total $7.47, Shop Voucher Discount −$3.00, Bazaario Voucher Discount −$3.00, Coins Redeemed −$4.99, Total Payment $49.44, \"Earn 49 coins\".","Placing the order shows \"Order placed\" with three order IDs and opens \"To Ship (3)\".","Orders: Volt Gadget Hub $24.46, Kitty Corner Home $10.98, Linen & Loom $14.00, totalling $49.44; the coins balance reads 751; the cart is empty."]},{"id":"FR-CHK-02","weight":"Must","text":"Each shop in a checkout charges its own shipping fee, and the Shipping Total is their sum.","accept":["Mouse Silent Black and Canvas Tote Bag: Volt Gadget Hub \"Standard Delivery · $1.99\", Linen & Loom \"Standard Delivery · $2.99\", Shipping Total $4.98, Total Payment $32.96."]},{"id":"FR-CHK-03","weight":"Must","text":"An order cannot be placed without a payment method, and Cash on Delivery is unavailable for orders over $100.00.","accept":["Place Order with no method shows \"Choose a payment method\" and creates no order.","With Merchandise Subtotal $159.98 and Shipping Total $0.00 (both shops reach free shipping) the Cash on Delivery tile is disabled and says it is not available for orders over $100.00."]},{"id":"FR-CHK-04","weight":"Must","text":"Paying with BazaarPay requires a 6-digit PIN; wrong PINs count down the remaining tries and the correct PIN pays from the balance.","accept":["The chosen address can be changed (Office: \"900 Lake Shore Dr, Floor 12, Chicago, IL 60611\").","Place Order opens \"Enter your BazaarPay PIN\" with 6 boxes.","A wrong PIN shows \"Incorrect PIN. 2 tries left.\" and clears the boxes.","The correct PIN shows \"Order placed\", opens To Ship and lowers the balance by the order total."]},{"id":"FR-CHK-05","weight":"Must","text":"Three wrong PINs lock BazaarPay for 15 minutes, and a correct PIN is refused while locked.","accept":["Second and third wrong PINs show \"Incorrect PIN. 1 try left.\" then \"BazaarPay is locked for 15 minutes after 3 wrong PINs\".","Placing again with the correct PIN stays locked and creates no order."]},{"id":"FR-CHK-06","weight":"Should","text":"A bank-transfer order waits in To Pay with a virtual account and a pay-within countdown until the buyer marks it transferred.","accept":["My Purchases opens on \"To Pay (1)\"; the card shows \"TO PAY\", \"Transfer $X to virtual account 8808 …\" and a \"Pay within\" countdown below 15:00 that keeps ticking.","\"I've Transferred\" shows \"Payment confirmed\" and the order moves to To Ship."]}]},{"id":"ORD","title":"Order lifecycle","summary":"Orders can be cancelled, received, rated, returned and searched.","reqs":[{"id":"FR-ORD-01","weight":"Should","text":"Cancelling a To Ship order requires a reason, refunds the BazaarPay balance and records the reason.","accept":["Confirming without a reason shows \"Select a cancellation reason\".","Choosing \"Don't want to buy anymore\" shows \"Order … cancelled\" and the balance returns to $150.00.","The Cancelled tab shows \"Cancellation reason: Don't want to buy anymore\" beside seeded order BZ-29990042."]},{"id":"FR-ORD-02","weight":"Could","text":"Confirming receipt earns coins; a rating needs stars and at least 15 characters; completed orders can be returned; orders can be searched.","accept":["Order Received on BZ-29990101 shows \"Thanks! +26 coins\" and the coins line reads 1,276.","Rating without stars or with \"Nice mugs\" is refused (\"Tap the stars to rate\" or \"Write at least 15 characters to earn coins\").","5 stars with \"Lovely glaze and sturdy handles\" shows \"Thanks for your rating! +20 coins\" and \"You rated ★★★★★\".","Return/Refund with \"Item is damaged\" shows \"Return requested\" and moves the order to the Return Refund tab.","Searching orders for \"tote\" shows only BZ-29990042 (Linen & Loom)."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"All money rules (voucher minimums, flash limits, stock, shipping, COD limit, PIN lock) are enforced by the server, not only by the screen.","accept":["A rule refused on screen is also refused when the request is sent directly."]},{"id":"NFR-02","weight":"Should","text":"Every refusal tells the buyer why, with the exact reason and amount still needed."}],"glossary":[{"term":"Flash Sale","meaning":"A timed discount with a per-buyer unit limit."},{"term":"Shop voucher","meaning":"A voucher claimed by following a shop and valid only for that shop subtotal."},{"term":"Platform voucher","meaning":"A Bazaario-wide voucher such as FREESHIP, BIG10 or NEWUSER5."},{"term":"BazaarPay","meaning":"The in-app wallet protected by a 6-digit PIN."},{"term":"Coins","meaning":"Loyalty points redeemable at 1 cent each against an order."}],"outOfScope":["Real payments, shipping or chat with sellers","Product review lists","Accessibility audit"]},{"ns":"coffee","title":"Brew Bar","version":"1.0","summary":"Brew Bar is a one-page coffee ordering app. Customers click cup illustrations to add drinks, preview and edit the cart, accept an occasional lucky-day Mocha offer, pay through a short form and review past orders. The server re-prices every cart from the menu.","problem":"Ordering a few drinks should take seconds. Customers need a cart whose total is always right, an offer that is applied exactly as advertised and a checkout that refuses bad details instead of creating a broken order.","personas":[{"name":"Customer","need":"Pick drinks quickly, see an accurate total and pay without surprises."},{"name":"Cafe owner","need":"Have every order priced from the menu and promotions limited to one per order."}],"goals":["Every total shown equals the sum of menu prices and offer prices.","Orders are created only from valid buyer details.","Repeated clicks or reloads never add or lose cups by accident."],"features":[{"id":"MNU","title":"Menu and adding drinks","summary":"The menu shows nine drinks as clickable cup illustrations.","reqs":[{"id":"FR-MNU-01","weight":"Must","text":"The menu lists nine drinks with their prices, and the pay button starts at $0.00.","accept":["Espresso $10.00, Espresso Macchiato $12.00, Cappuccino $19.00, Mocha $8.00, Flat White $18.00, Americano $7.00, Cafe Latte $16.00, Espresso Con Panna $14.00, Cafe Breve $15.00.","The pay button reads \"Total: $0.00\" and the header \"cart (0)\"."]},{"id":"FR-MNU-02","weight":"Must","text":"Clicking a cup adds one of that drink and updates the total and cart count.","accept":["Cappuccino gives \"Total: $19.00\" and \"cart (1)\"; adding Espresso gives \"Total: $29.00\" and \"cart (2)\"."]},{"id":"FR-MNU-03","weight":"Could","text":"Double-clicking a drink title toggles between its English and Chinese name without changing the cart.","accept":["\"Espresso\" becomes \"特浓咖啡 $10.00\" and back to \"Espresso $10.00\"; the total stays $0.00."]},{"id":"FR-MNU-04","weight":"Could","text":"Each cup illustration shows its ingredients as layers.","accept":["Mocha shows espresso, chocolate syrup, steamed milk and whipped cream."]},{"id":"FR-MNU-05","weight":"Should","text":"Cups can be focused and added from the keyboard with Enter or Space.","accept":["Enter on the focused Espresso gives \"Total: $10.00\"; Space then gives \"Total: $20.00\"."]},{"id":"FR-MNU-06","weight":"Should","text":"When adding is slow, one click adds exactly one cup and repeated clicks while brewing add nothing.","accept":["Clicking Espresso shows \"Brewing Espresso…\" and after about 1.5 seconds \"Total: $10.00\".","Three quick clicks on Americano add one Americano: \"Total: $17.00\", \"cart (2)\"."]}]},{"id":"PRV","title":"Cart preview and cart page","summary":"The cart can be previewed by hovering the pay button and edited on a cart page.","reqs":[{"id":"FR-PRV-01","weight":"Should","text":"Hovering the pay button shows a cart preview, with + and - controls, that closes when the pointer leaves.","accept":["With Flat White and Americano the preview lists \"Flat White x 1\" and \"Americano x 1\".","\"+\" on Flat White gives \"Flat White x 2\" and \"Total: $43.00\"; \"-\" on Americano removes it and gives \"Total: $36.00\"."]},{"id":"FR-PRV-02","weight":"Must","text":"The cart page shows unit price, quantity and line total for each drink, and its total equals the pay button total.","accept":["Two Cafe Lattes and one Cafe Breve: \"Cafe Latte $16.00 x 2 $32.00\", \"Cafe Breve $15.00 x 1 $15.00\", \"Total: $47.00\"."]},{"id":"FR-PRV-03","weight":"Must","text":"\"+\" and \"-\" change a line quantity by exactly one; \"-\" on a single cup removes the line.","accept":["Two Mochas then \"-\" gives \"Mocha $8.00 x 1 $8.00\" and \"Total: $8.00\"; \"+\" twice gives \"x 3 $24.00\".","Three Mochas then \"-\" gives \"x 2 $16.00\".","\"-\" until nothing remains shows \"No coffee, go add some.\""]},{"id":"FR-PRV-04","weight":"Should","text":"\"x\" removes every cup of that drink.","accept":["Two Espressos and one Flat White: \"x\" on Espresso leaves Flat White, \"Total: $18.00\", \"cart (1)\"."]},{"id":"FR-PRV-05","weight":"Could","text":"An empty cart page shows a friendly message and no pay button.","accept":["\"No coffee, go add some.\" is shown with no \"Total\" button."]},{"id":"FR-PRV-06","weight":"Should","text":"The cart is kept for the browser tab across reloads.","accept":["One Cappuccino then a reload still shows \"Total: $19.00\" and \"cart (1)\"."]}]},{"id":"PRM","title":"Lucky-day Mocha offer","summary":"After every third cup a discounted Mocha is offered.","reqs":[{"id":"FR-PRM-01","weight":"Must","text":"After the third cup the dialog \"It's your lucky day! Get an extra cup of Mocha for $4.\" appears; accepting adds one discounted Mocha charged exactly $4.00.","accept":["Espresso, Americano and Mocha show the dialog with \"Total: $25.00\".","\"Yes, of course!\" gives \"Total: $29.00\" and \"cart (4)\"; the cart page lists \"(Discounted) Mocha\" $4.00 x 1 = $4.00.","Three Americanos plus acceptance give \"Total: $25.00\"."]},{"id":"FR-PRM-02","weight":"Should","text":"Declining the offer leaves the cart unchanged.","accept":["Three Americanos then \"Nah, I'll skip.\" gives \"Total: $21.00\" and \"cart (3)\"."]},{"id":"FR-PRM-03","weight":"Should","text":"The discounted Mocha is limited to one per order; its \"+\" is disabled and the offer is not shown again while it is in the cart.","accept":["The \"+\" on the discounted line is disabled.","Adding three more cups shows no lucky-day dialog."]}]},{"id":"PAY","title":"Checkout","summary":"A payment dialog collects name and email and creates an order.","reqs":[{"id":"FR-PAY-01","weight":"Must","text":"Paying for a cart shows a confirmation, resets the cart and records an order in history.","accept":["Cappuccino and Espresso: the pay button opens \"Payment details\" with Name, Email and a promotional-messages checkbox.","Submitting valid details shows \"Thanks for your purchase. Please check your email for payment.\" and resets the total to $0.00.","The newest order BB-1006 shows the buyer, 2 cups and $29.00."]},{"id":"FR-PAY-02","weight":"Must","text":"Name and email are required; submitting without them is refused and the dialog stays open.","accept":["Both empty: \"Name is required\" and \"Email is required\".","Name only: \"Email is required\"."]},{"id":"FR-PAY-03","weight":"Must","text":"A malformed email is refused with \"Enter a valid email address\"; the cart is kept and no order is created.","accept":["The cart still holds the Americano and no confirmation appears.","The orders page shows no new order for that buyer."]},{"id":"FR-PAY-04","weight":"Should","text":"Closing the payment dialog with the close button or the backdrop keeps the cart.","accept":["One Mocha: \"×\" closes the dialog with \"Total: $8.00\"; a click on the backdrop also closes it."]}]},{"id":"ORD","title":"Order history","summary":"Past orders are listed newest first and expand into their lines.","reqs":[{"id":"FR-ORD-01","weight":"Should","text":"Orders are listed newest first and each expands into its lines and total.","accept":["Five seeded orders BB-1005 to BB-1001 are listed; BB-1005 is Rowan Tate, $29.00.","BB-1002 shows \"Espresso $10.00 x 3 · (Discounted) Mocha $4.00 x 1\" with total $34.00."]}]},{"id":"API","title":"Server pricing","summary":"The server prices carts and checkouts from the menu.","reqs":[{"id":"FR-API-01","weight":"Must","text":"Quote and checkout compute totals from the menu, reject a stale client total and refuse more than one discounted Mocha.","accept":["Quote for 2 Mochas and 1 discounted Mocha returns 200 with total 2000 (cents).","Checkout with one Cappuccino and an expected total of 1000 returns 409 \"Prices changed — please review your cart\" with total 1900.","Quote for 2 discounted Mochas returns 422 \"Only one discounted Mocha per order\"."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Prices and offers are decided by the server, never trusted from the browser.","accept":["A client total that differs from the menu price is rejected."]}],"glossary":[{"term":"Lucky-day offer","meaning":"A discounted Mocha offered after every third cup."},{"term":"Cart preview","meaning":"The list shown while hovering the pay button."}],"outOfScope":["Real payment","Email delivery"]},{"ns":"todos","title":"Task List","version":"2.4","summary":"Task List is a classic personal todo list. People type a title and press Enter to add a task, tick tasks off, edit them inline, delete them, switch between All, Active and Completed views and clear what is done. Each browser tab keeps its own list on the server.","problem":"A todo list is only useful if the counter, the visible items and the stored list always agree, and if keyboard-only use is as complete as mouse use.","personas":[{"name":"Everyday user","need":"Capture, tick off and tidy tasks quickly, mostly from the keyboard."}],"goals":["The \"items left\" counter always equals the number of active todos.","Every edit gesture (Enter, Escape, blur, empty title) has one predictable outcome.","Bulk actions only affect the todos they promise to affect."],"features":[{"id":"LST","title":"List and counter","summary":"Seeded list, footer counter and empty state.","reqs":[{"id":"FR-LST-01","weight":"Must","text":"A fresh tab shows three seeded todos, with the completed one struck through, and a footer with the active counter, the All / Active / Completed filters and Clear completed.","accept":["Buy oat milk (active), Book dentist appointment (completed), Renew library card (active)","Footer \"2 items left\" with All selected"]},{"id":"FR-LST-02","weight":"Must","text":"The footer counter equals the number of active (not completed) todos, and is singular for one: \"1 item left\".","accept":["Ticking Buy oat milk: \"1 item left\"; unticking: \"2 items left\"","All completed: \"0 items left\""]},{"id":"FR-LST-03","weight":"Could","text":"With no todos the list, footer and toggle-all chevron are hidden and a hint is shown.","accept":["\"Nothing to do — add your first todo above.\""]}]},{"id":"ADD","title":"Adding todos","summary":"Keyboard-only entry.","reqs":[{"id":"FR-ADD-01","weight":"Must","text":"Typing a title in \"What needs to be done?\" and pressing Enter appends an unchecked todo and empties the input; there is no add button.","accept":["\"Water the plants\" is last and unchecked; \"3 items left\""]},{"id":"FR-ADD-02","weight":"Should","text":"Todos keep the order in which they were added.","accept":["Three more todos added in sequence appear after the seeded three in the same order; \"5 items left\""]},{"id":"FR-ADD-03","weight":"Should","text":"Empty or whitespace-only input adds nothing; surrounding spaces are trimmed from a valid title.","accept":["Enter on empty and on \"     \" leaves three todos","\"   Feed the cat   \" is added as \"Feed the cat\""]},{"id":"FR-ADD-04","weight":"Could","text":"Titles are shown as plain text and long titles wrap inside the list.","accept":["<b>bold?</b> & \"quotes\" is displayed literally, not as bold","A 100-character title wraps without horizontal scroll"]}]},{"id":"CMP","title":"Completing todos","summary":"Single and bulk completion.","reqs":[{"id":"FR-CMP-01","weight":"Must","text":"The checkbox toggles a todo between active and completed (struck through) and updates the counter."},{"id":"FR-CMP-02","weight":"Must","text":"The chevron left of the input (\"Mark all as complete\") completes every todo; clicking it again reopens every todo and hides Clear completed.","accept":["All three struck through, \"0 items left\"; again: none completed, \"3 items left\""]},{"id":"FR-CMP-03","weight":"Must","text":"Clear completed removes only the completed todos and then hides itself.","accept":["After ticking Buy oat milk: only Renew library card remains, \"1 item left\", button hidden","With the seed, clearing leaves Buy oat milk and Renew library card and \"2 items left\"","Mark all then Clear completed leaves the empty-list state"]}]},{"id":"EDT","title":"Editing and deleting","summary":"Inline edit and hover delete.","reqs":[{"id":"FR-EDT-01","weight":"Must","text":"Double-click opens an inline editor with the current text; Enter saves the trimmed text and the change survives a reload.","accept":["\"Renew library card online  \" is saved as \"Renew library card online\""]},{"id":"FR-EDT-02","weight":"Should","text":"Escape discards the edit and keeps the original title.","accept":["Typing \"Buy almond milk\" then Escape leaves \"Buy oat milk\""]},{"id":"FR-EDT-03","weight":"Should","text":"Clicking elsewhere (blur) saves the edit.","accept":["\"Buy oat milk and bread\" is kept after clicking the heading"]},{"id":"FR-EDT-04","weight":"Must","text":"Saving an empty title deletes the todo; no blank row is ever left.","accept":["Clearing Renew library card and pressing Enter leaves two todos and \"1 item left\""]},{"id":"FR-EDT-05","weight":"Should","text":"A × button is revealed only while hovering a row and deletes that todo.","accept":["No × without hover; after hover and click, Buy oat milk is gone and \"1 item left\""]}]},{"id":"FLT","title":"Filters","summary":"Hash-routed All / Active / Completed views.","reqs":[{"id":"FR-FLT-01","weight":"Should","text":"Filters show the right subset and are reflected in the URL hash; the current filter is outlined.","accept":["Active: #/active, Buy oat milk and Renew library card","Completed: #/completed, only Book dentist appointment","All shows all three"]},{"id":"FR-FLT-02","weight":"Could","text":"A filter URL opens directly on that filter and the browser Back button returns to the previous filter.","accept":["/todos/#/completed shows only Book dentist appointment; Active then Back shows Completed again"]},{"id":"FR-FLT-03","weight":"Should","text":"Completing a todo while the Active filter is selected removes it from the view.","accept":["Ticking Buy oat milk: it disappears, \"1 item left\""]}]},{"id":"PER","title":"Persistence and API","summary":"Per-tab server state and REST interface.","reqs":[{"id":"FR-PER-01","weight":"Should","text":"Changes survive a reload in the same tab; a new tab starts from the seeded three todos.","accept":["Added Water the plants, reload: four todos, \"3 items left\"; new tab: seeded three"]},{"id":"FR-PER-02","weight":"Should","text":"The REST API supports list, add, update, delete and reset with specific errors.","accept":["GET lists 3 todos, active 2, completed 1","Blank title: 422 \"Title is required\"","Add returns 201 with id t4; PATCH completed true and DELETE return 200; DELETE again 404","Reset restores the seeded three"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Data integrity: the counter, the visible list and the stored list never disagree after any add, edit, delete, toggle or clear."},{"id":"NFR-02","weight":"Should","text":"Keyboard accessibility: add, save and cancel are available from the keyboard (Enter, Escape)."}],"glossary":[{"term":"Active","meaning":"A todo that is not completed."},{"term":"Toggle all","meaning":"The chevron left of the input that completes or reopens every todo."}],"outOfScope":["Drag to reorder","Multiple lists"]},{"ns":"otp","title":"Passcode","version":"1.0","summary":"Passcode is a passwordless sign-in service. A person enters an email address, receives a 6-digit one-time code in a built-in demo mailbox, types it into six one-digit boxes and lands on an account page. It also offers sign-up, resend, expiry, attempt limits and a small HTTP API.","problem":"Passwords are forgotten and reused. People want to sign in with only an email address, and the service must make sure that only the person who controls the mailbox, holding the newest unexpired code, can get in.","personas":[{"name":"Returning member","need":"Sign in with just an email address and a short code, quickly and without a password."},{"name":"New visitor","need":"Create an account with a name and email and be signed in with the first code."},{"name":"Account holder under attack","need":"Be protected from guessed, replayed or stale codes."},{"name":"Integrator","need":"Do the same flow over HTTP with a bearer token as the result."}],"goals":["Only the newest, unexpired code for an address can sign a person in, and only within three tries.","Every failure explains itself in one clear sentence and tells the person what to do next.","The code shown in the mailbox is exactly the code the service accepts.","The flow works with keyboard, paste and reload without losing the person in the middle."],"features":[{"id":"SGN","title":"Sign-up","summary":"Self-service account creation with a name and an inbox address.","reqs":[{"id":"FR-SGN-01","weight":"Must","text":"Creating an account with a name and an unused address sends a code and signing in with it opens the account page.","accept":["\"Create account\" moves to a \"Check your inbox\" screen saying a 6-digit code was sent to a masked address such as a•••@inbox.test","After the sixth digit is typed the code verifies automatically and the page shows \"Welcome back, <name>\" with Plan \"Free\""]},{"id":"FR-SGN-02","weight":"Should","text":"Sign-up requires a name and an unused address on the demo mail domain.","accept":["An empty form shows \"Enter your name\" and \"Enter your email\"","An address outside the demo domain shows \"Use an @inbox.test address so you can read the code\"","An address that already has an account shows \"An account with this email already exists — sign in instead\""]}]},{"id":"REQ","title":"Requesting a code","summary":"The sign-in screen asks for an email address and sends a code to existing accounts only.","reqs":[{"id":"FR-REQ-01","weight":"Must","text":"An existing member who enters their email address is sent a code and, after entering it, sees their account details.","accept":["The screen says \"We sent a 6-digit code to d•••@inbox.test\" for dev.patel@inbox.test","After the code the page shows \"Welcome back, Dev Patel\", Plan \"Free\", Member since 2026-01-09 and a first recent sign-in marked \"— this session\"","If a code was requested for the same address in the last 30 seconds the message is \"Please wait N s\" instead"]},{"id":"FR-REQ-02","weight":"Should","text":"An address without an account is rejected with a link to sign up.","accept":["The message is \"No account found for this email · Create an account\" and the person stays on the sign-in screen"]},{"id":"FR-REQ-03","weight":"Should","text":"Empty and malformed addresses are rejected before any code is sent.","accept":["An empty field shows \"Enter your email\"","\"mira.okafor@inbox\" shows \"Enter a valid email address\""]},{"id":"FR-REQ-04","weight":"Should","text":"A suspended account cannot request a code and receives no code mail.","accept":["The message is \"This account is suspended. Contact support.\"","The mailbox for that address holds only \"Your account has been suspended\" and no sign-in code"]}]},{"id":"MBX","title":"Demo mailbox","summary":"A built-in mailbox page, opened in a second tab, stands in for real email.","reqs":[{"id":"FR-MBX-01","weight":"Must","text":"Each code email shows the 6-digit code and its expiry, and that code is accepted when entered exactly as shown.","accept":["The list shows a masked subject \"•••••• is your Passcode sign-in code\"; the full code is visible only inside the message","The message body shows a 6-digit code and its expiry time","Entering the code exactly as displayed signs the person in; it is never rejected as incorrect"]},{"id":"FR-MBX-02","weight":"Could","text":"The mailbox lists the messages for one address, newest first.","accept":["mira.okafor@inbox.test shows at least \"New sign-in from Firefox on Linux\" and \"Welcome to Passcode\", newer above older","The welcome message body starts \"Hi Mira Okafor,\""]}]},{"id":"VER","title":"Verifying a code","summary":"Entering the code, with a limited number of tries and a limited lifetime.","reqs":[{"id":"FR-VER-01","weight":"Should","text":"A wrong code is rejected, the boxes clear, and the remaining attempts count down.","accept":["First wrong code: \"Incorrect code. 2 attempts left.\"","Second wrong code: \"Incorrect code. 1 attempt left.\""]},{"id":"FR-VER-02","weight":"Must","text":"Three wrong codes lock the current code: even the correct code is refused until a new code is requested.","accept":["The third wrong code shows \"Too many attempts. Request a new code.\" and the boxes are disabled","After a resend the message is \"A new code is on its way. Earlier codes no longer work.\", the boxes are enabled and the newest code signs the person in","No number of further tries on the locked code can succeed"]},{"id":"FR-VER-03","weight":"Should","text":"A code expires after its lifetime and is then refused.","accept":["The default lifetime is 2 minutes; the verify screen shows a live \"Code expires in m:ss\" and then \"Code expired\"","A shorter lifetime can be set with an optional ttl URL parameter (minimum 5 seconds); with 10 seconds the mail says \"It expires in 10 seconds\"","An expired code shows \"This code has expired. Request a new code.\""]}]},{"id":"RSD","title":"Resend","summary":"Asking for another code, rate-limited and replacing earlier codes.","reqs":[{"id":"FR-RSD-01","weight":"Should","text":"Resend is disabled for 30 seconds after a code is sent, with a live countdown.","accept":["The button reads \"Resend code in 30 s\", is disabled and counts down every second","When the countdown ends it reads \"Resend code\" and is enabled"]},{"id":"FR-RSD-02","weight":"Must","text":"A resent code replaces every earlier code: only the newest works.","accept":["After a resend the mailbox holds two code mails, newest on top","Entering the older code shows \"This code is no longer valid. Request a new code.\"","Entering the newest code signs the person in"]}]},{"id":"BOX","title":"Code boxes","summary":"Six one-digit boxes that behave like a single input.","reqs":[{"id":"FR-BOX-01","weight":"Could","text":"Pasting a 6-digit code into the first box fills every box and submits.","accept":["All six boxes fill and the account page opens without further clicks"]},{"id":"FR-BOX-02","weight":"Could","text":"Each box accepts one digit, focus advances, non-digits are ignored and Backspace moves back.","accept":["Typing \"12\" fills box 1 and box 2 and focuses box 3","Typing a letter enters nothing","Backspace clears the previous box and puts focus there"]}]},{"id":"SES","title":"Session and routing","summary":"A signed-in session persists across reloads and ends on sign-out.","reqs":[{"id":"FR-SES-01","weight":"Must","text":"A signed-in tab stays signed in after reload; signing out ends the session and protects the account page.","accept":["Reloading the account page still shows \"Welcome back, <name>\"","\"Sign out\" returns to the sign-in screen","Opening the account page directly afterwards redirects to the sign-in screen"]},{"id":"FR-SES-02","weight":"Could","text":"Opening the verify screen with no pending code returns to sign-in.","accept":["The address becomes the sign-in page and the \"Sign in\" heading is shown"]}]},{"id":"API","title":"HTTP API","summary":"The same flow over HTTP, ending in a bearer token.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The API supports sign-up, request, mailbox read, verify and account, with the same cooldown and code rules as the website.","accept":["Sign-up answers 201; a first code request answers 200 with sentTo, ttl 120 and resendIn 30","A second request straight away answers 429 \"Please wait N s…\" with retryAfter","Verify with the mailbox code answers 200 with a token and user","The account endpoint answers 200 with a bearer token and 401 \"Sign in first\" without it"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Codes are single-purpose: 6 digits, valid for one address, never reusable once superseded, expired or locked."},{"id":"NFR-02","weight":"Should","text":"Addresses are masked on screen (first letter then dots) so the screen does not reveal the full address."}],"glossary":[{"term":"Code","meaning":"A 6-digit one-time number emailed to the address; valid for 2 minutes by default."},{"term":"Superseded code","meaning":"A code replaced by a newer one after a resend."}],"outOfScope":["Real email delivery","SMS codes","Password sign-in"]},{"ns":"mart","title":"Corner Mart","version":"1.0","summary":"Corner Mart is a grocery delivery shop with admin-approved accounts. Customers register, wait for an admin to approve them, sign in, browse a catalogue with stock limits, fill a cart, pick a delivery slot and place orders that they can cancel until packed. Admins approve or reject registrations.","problem":"A delivery shop must only serve vetted customers, keep sessions and redirects predictable, and charge delivery and enforce minimums exactly as promised. Wrong rules cost money or lock out good customers.","personas":[{"name":"Customer","need":"Sign in once, stay signed in, order groceries into a delivery slot and know the exact total."},{"name":"Admin","need":"Approve or reject new registrations with a reason the applicant can read."},{"name":"Applicant","need":"Know whether the registration is pending or rejected and why."}],"goals":["Only approved accounts can sign in or call the API.","A signed-in session survives a reload and ends on logout.","Minimum order, free-delivery and stock rules are applied exactly at their boundaries.","Delivery slots never exceed their capacity."],"features":[{"id":"AUT","title":"Sign in, session and redirect","summary":"Approved customers sign in, are redirected to the page they asked for and stay signed in.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Valid credentials show the browser alert \"✅ Login successful\"; accepting it opens the Shop with the customer name and a Logout button.","accept":["The Login page has Username and Password fields and the password is masked.","After accepting the alert the header reads \"Hi, Asha\" and shows Logout."]},{"id":"FR-AUT-02","weight":"Must","text":"The session survives a page reload.","accept":["After a reload the user is still on Shop, still \"Hi, Asha\" with Logout; the Login page is not shown."]},{"id":"FR-AUT-03","weight":"Must","text":"Invalid or empty credentials never show the success alert, and the error does not reveal which part was wrong.","accept":["Both fields empty: \"Please enter username and password\".","Wrong password or unknown username: the same \"Invalid username or password\"; still on Login."]},{"id":"FR-AUT-04","weight":"Should","text":"Opening a protected page while signed out redirects to Login with \"Please log in to continue\" and, after sign-in, returns to the requested page.","accept":["Opening My orders signed out goes to /mart/?next=%2Forders with \"Please log in to continue\".","After sign-in My orders is shown (not Shop) listing CM-2057 and CM-2041.","Opening the Cart signed out and signing in shows the Cart page."]},{"id":"FR-AUT-05","weight":"Should","text":"Logout ends the session; protected pages require sign-in again, including through the Back button and a reload.","accept":["Logout shows Login with \"You have been logged out.\" and no Logout in the header.","Back does not show Shop; Login shows \"Please log in to continue\"; a reload stays on Login."]}]},{"id":"REG","title":"Registration and approval","summary":"New accounts stay pending until an admin approves or rejects them.","reqs":[{"id":"FR-REG-01","weight":"Must","text":"A registration is accepted but stays pending, and a pending account cannot sign in.","accept":["Registering shows \"Registration received\" with the account \"pending approval\".","Signing in as a pending account shows \"Your account is awaiting approval. You can sign in once an admin approves it.\" with no alert and stays on Login."]},{"id":"FR-REG-02","weight":"Must","text":"A rejected account cannot sign in and sees the rejection reason.","accept":["Sign-in shows \"Your registration was rejected: Address is outside our delivery area\"."]},{"id":"FR-REG-03","weight":"Should","text":"Each invalid registration field shows its own message.","accept":["All empty: \"Full name is required\", \"Username is required\", \"Password must be at least 4 characters\", \"Phone must look like 555-0123\", \"Delivery address is required\".","An existing username shows \"That username is taken\".","Different confirmation shows \"Passwords do not match\".","Username \"pr\" shows \"Username must be 3–20 letters, digits, dots or underscores\"."]},{"id":"FR-REG-04","weight":"Must","text":"An admin approves a pending registration and the user can then sign in.","accept":["The Account approvals page opens with \"Pending (2)\" listing Ravi Kumar and Lena Fischer.","Approve on Lena Fischer shows \"Lena Fischer approved — they can sign in now.\" and \"Pending (1)\".","Lena then signs in with \"✅ Login successful\" and sees \"Hi, Lena\"."]},{"id":"FR-REG-05","weight":"Should","text":"Rejecting requires a reason, and the reason reaches the applicant.","accept":["Confirm reject with an empty reason shows \"Give a reason for the rejection\"; Ravi stays pending.","Reason \"Phone number not reachable\" shows \"Ravi Kumar rejected.\" and the All customers list shows \"rejected\" with that reason.","Ravi's sign-in shows \"Your registration was rejected: Phone number not reachable\"."]},{"id":"FR-REG-06","weight":"Could","text":"Customers do not see the approvals page.","accept":["A customer has no \"Approvals\" link and /mart/admin redirects to Shop."]}]},{"id":"CAT","title":"Catalogue and stock","summary":"The catalogue has categories, search, an in-stock filter and sorting with per-item limits.","reqs":[{"id":"FR-CAT-01","weight":"Should","text":"Quantities stop at the stock level or 10, whichever is lower, and out-of-stock items cannot be added.","accept":["Vine Tomatoes and Salted Butter say \"Out of stock\" with disabled Add buttons.","Avocados show \"Only 3 left\" and the quantity stops at 3 with + disabled.","Bananas (stock 40) stop at 10."]},{"id":"FR-CAT-02","weight":"Should","text":"Search, category tabs, the in-stock filter and sorting combine correctly.","accept":["The full catalogue reads \"24 products\"; \"Dairy & Eggs\" reads \"5 products\"; \"In stock only\" reads \"4 products\" without Salted Butter.","\"Price: high to low\" starts with Aged Cheddar ($6.25) and ends with Whole Milk ($2.89).","Search \"oil\" finds only Extra Virgin Olive Oil; \"zzz\" shows \"0 products\" and \"No products match your filters.\""]}]},{"id":"CRT","title":"Cart, minimum and delivery fee","summary":"The cart enforces a $10.00 minimum order and free delivery from $35.00.","reqs":[{"id":"FR-CRT-01","weight":"Must","text":"An order below $10.00 cannot be placed and the cart states the shortfall.","accept":["Gala Apples × 1 ($3.49) and Penne Pasta × 2 ($3.78): Subtotal (3 items) $7.27, Delivery $2.99, Total $10.26.","\"Add $2.73 more to reach the $10.00 minimum order.\" and Place order is disabled.","Penne Pasta at 4 gives Subtotal $11.05, \"Add $23.95 more for free delivery.\" and an enabled Place order."]},{"id":"FR-CRT-02","weight":"Must","text":"Delivery is free when the subtotal is $35.00 or more (inclusive); below that it costs $2.99.","accept":["A subtotal of exactly $35.00 (6 items) shows Delivery \"Free\" and Total $35.00.","Reducing to Subtotal $26.01 shows Delivery $2.99, Total $29.00 and \"Add $8.99 more for free delivery.\""]}]},{"id":"ORD","title":"Checkout, slots and orders","summary":"Orders are placed into delivery slots with limited capacity and can be cancelled until packed.","reqs":[{"id":"FR-ORD-01","weight":"Must","text":"Placing an order needs a delivery slot and a payment choice; the order appears in history and can be cancelled while not yet packed.","accept":["Basmati Rice × 2: Subtotal $15.98, Delivery $2.99, Total $18.97, address prefilled \"48 Harbour Lane\".","Place order with no choices shows \"Choose a delivery slot\".","After placing, My orders shows \"Order CM-2064 placed…\", status Placed, $18.97, above CM-2063 (Packed); the cart badge is 0.","CM-2063 (Packed) has no Cancel order button; \"Cancel order\" then \"Yes, cancel it\" on CM-2064 makes it Cancelled."]},{"id":"FR-ORD-02","weight":"Should","text":"Slot capacity decreases with every order, and a slot with no places left is Full and cannot be chosen.","accept":["\"Today · 18:00–20:00\" is Full and disabled; \"Tomorrow · 12:00–14:00\" has 1 left.","After one order in \"Tomorrow · 12:00–14:00\" it says Full and cannot be selected."]},{"id":"FR-ORD-03","weight":"Should","text":"Order history lists orders newest first with correct lines, delivery fee and total.","accept":["CM-2057 (Delivered, $37.15) is above CM-2041 (Delivered, $16.84).","CM-2057: Basmati Rice 2 × $7.99 = $15.98, Extra Virgin Olive Oil 1 × $11.99, Free-range Eggs 2 × $4.59 = $9.18; Delivery Free; Total $37.15.","CM-2041: Whole Milk, Sourdough Loaf, Bananas; Delivery $2.99; Total $16.84."]}]},{"id":"API","title":"Login API","summary":"The API applies the same approval rules as the screen.","reqs":[{"id":"FR-API-01","weight":"Must","text":"The login API returns a token only for approved accounts, refuses pending accounts and requires a token for private resources.","accept":["Login for an approved account returns 200 with a token and user.status \"approved\".","GET /api/mart/me with the token returns 200 and the name \"Asha Menon\".","Login for a pending account returns 403 with code \"pending\".","GET /api/mart/cart without a token returns 401 \"Please log in to continue\"."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Account approval, minimum order and delivery rules are enforced by the server, not only by the screen.","accept":["The API refuses a pending account exactly as the Login page does."]}],"glossary":[{"term":"Pending","meaning":"A registration waiting for an admin decision."},{"term":"Slot","meaning":"A delivery window with a limited number of places."},{"term":"Packed","meaning":"An order stage after which cancellation is no longer offered."}],"outOfScope":["Real payments","Sessions shared between browser tabs (each tab is its own store)"]},{"ns":"cardgen","title":"Test Card Generator","version":"2.0","summary":"Cardgen is a test-data tool for developers and testers. It generates batches of fake payment card numbers for a chosen network (Vela, Orbit, Atlas Express, Summit Club, Comet, Lotus, Tidal or a random mix), validates any number with the Luhn checksum and network rules, and keeps a per-tab history of batches. The numbers are never real and nothing is charged.","problem":"Testers need dependable fake card data: exactly the number of cards requested, in the right length and format for each network, passing the checksum, and reproducible when they need the same data again.","personas":[{"name":"QA engineer","need":"Generate a known number of valid cards per network to fill a checkout test."},{"name":"Developer","need":"Validate a number and call the same rules from a script, with a seed for repeatable data."}],"goals":["A batch contains exactly the number of cards requested, from 1 to 50.","Every generated number has the length, prefix and CVV size of its network and passes the Luhn checksum.","The validator tells a valid number from an invalid one and says why."],"features":[{"id":"GEN","title":"Generating a batch","summary":"Choosing a type and count and reading the result.","reqs":[{"id":"FR-GEN-01","weight":"Must","text":"Choosing a card type and a count and clicking Generate shows a batch with exactly that many cards, whose heading names the count and the type.","accept":["The Generate tab opens with Card Type \"Select card type\"; while working the button reads \"Generating…\"","10 Vela: \"Generated 10 Vela cards\", exactly 10 cards (the result panel scrolls), each starting with 4 and having 16 digits in groups of 4","1 card: \"Generated 1 Vela card\" (singular)"]},{"id":"FR-GEN-02","weight":"Must","text":"The maximum count of 50 returns exactly 50 cards and every smaller count returns exactly that many.","accept":["50 Orbit: \"Generated 50 Orbit cards\"; in CSV output 50 data rows under the header","50 Vela: the heading says 50 and the panel holds 50 cards"]},{"id":"FR-GEN-03","weight":"Should","text":"A seed makes a batch reproducible: the same seed, type and count always give the same numbers in the same order; another seed gives different numbers.","accept":["Opening with ?seed=42 shows Seed 42 in Advanced options; the caption reads \"seed 42\"","Generating again gives a new batch id and the same three numbers; seed 43 gives different numbers"]}]},{"id":"CNT","title":"Count and type validation","summary":"Messages for invalid input; nothing is generated on error.","reqs":[{"id":"FR-CNT-01","weight":"Must","text":"The count must be a whole number from 1 to 50; every invalid count shows its own message under Number of Cards and no result panel appears.","accept":["Empty: \"Enter how many cards to generate (1–50)\"","0 or -1: \"Generate at least 1 card\"","2.5: \"Number of cards must be a whole number\"","51: \"You can generate at most 50 cards at a time\"","A valid count afterwards clears the message"]},{"id":"FR-CNT-02","weight":"Should","text":"A card type is required.","accept":["Generating without a type shows \"Choose a card type\" under Card Type and outlines the listbox button in red"]}]},{"id":"NET","title":"Network formats","summary":"Length, prefix and CVV size per network.","reqs":[{"id":"FR-NET-01","weight":"Must","text":"Atlas Express numbers have 15 digits grouped 4-6-5, start with 34 or 37 and carry a 4-digit CVV.","accept":["Every one of 5 cards matches; a 16-digit Atlas number is wrong"]},{"id":"FR-NET-02","weight":"Should","text":"Summit Club numbers have 14 digits grouped 4-6-4, start with 36 or 38 and carry a 3-digit CVV.","accept":["Every one of 4 cards matches"]},{"id":"FR-NET-03","weight":"Could","text":"\"Random (mixed)\" draws from several networks, each in its own format.","accept":["20 cards show at least 3 network names","Atlas numbers have 15 digits, Summit Club 14, the others 16"]}]},{"id":"TYP","title":"Card Type listbox","summary":"Keyboard operation of the custom control.","reqs":[{"id":"FR-TYP-01","weight":"Could","text":"The Card Type control works as a listbox from the keyboard and by mouse.","accept":["ArrowDown on the button opens the list with \"Random (mixed)\" highlighted","End highlights \"Tidal\"; Enter closes the list, shows \"Tidal\" and returns focus to the button","Escape closes the list and keeps the current choice","Choosing an option by mouse closes the list and shows the name"]}]},{"id":"VAL","title":"Validator","summary":"Luhn checksum and network rules.","reqs":[{"id":"FR-VAL-01","weight":"Must","text":"Every generated number passes the validator, which names the network and the checksum result.","accept":["A Comet number pasted into Validate: \"✓ Valid number\"; Network Comet; Luhn checksum Pass","Copy on a card briefly shows \"Copied\""]},{"id":"FR-VAL-02","weight":"Must","text":"The validator rejects a number whose checksum fails, whose length does not fit its network or whose prefix is unknown, and says why.","accept":["4111 1111 1111 1111: \"✓ Valid number\"; Network Vela; Digits 16; Luhn Pass","3782 822463 10005: \"✓ Valid number\"; Network Atlas Express; Digits 15","4111 1111 1111 1112: \"✗ Invalid number\"; Luhn checksum Fail; reason \"The Luhn checksum fails\"","4111 1111 1111 111: reason \"Vela numbers have 16 digits, this one has 15\"","9999 0000 1111 2222: Network Unknown; reason \"The prefix does not match a known network\"","The service: 5555 5555 5555 4444 gives {network:\"Orbit\", valid:true}"]},{"id":"FR-VAL-03","weight":"Should","text":"Spaces and dashes in the input are ignored; any other character is rejected.","accept":["Empty: \"Enter a card number\"","4111-1111-1111-1111 is valid and shown as 4111 1111 1111 1111","4111 1111 1111 111a: \"Card numbers contain digits only (spaces and dashes are ignored)\""]}]},{"id":"OPT","title":"Options and export","summary":"Include toggles, formats and bulk actions.","reqs":[{"id":"FR-OPT-01","weight":"Should","text":"The Include options hide CVV, expiry and cardholder name from the cards and from JSON and CSV output.","accept":["With CVV and Cardholder name unticked the cards show only \"Valid thru\"","JSON objects hold network, number and expiry; numbers have no spaces","CSV header is \"network,number,expiry\""]},{"id":"FR-OPT-02","weight":"Could","text":"\"Copy all\" and \"Download CSV\" export the whole batch.","accept":["\"Copied 4 cards\" appears briefly","The file cards-B-<n>.csv has a header and 4 rows"]}]},{"id":"HIS","title":"History","summary":"Per-tab list of batches.","reqs":[{"id":"FR-HIS-01","weight":"Could","text":"History lists the newest batch first and can reopen a batch.","accept":["A fresh tab lists B-2 (Orbit, 3 cards, seed 2024) above B-1 (Vela, 5 cards, seed 7)","Open on B-1 shows \"Generated 5 Vela cards\" and the caption \"Batch B-1 · seed 7\"","A new batch of 2 Random (mixed) cards appears first as B-3"]},{"id":"FR-HIS-02","weight":"Could","text":"Clear history asks for confirmation and, once confirmed, stays cleared after a reload.","accept":["\"Delete all batches?\" with Delete and Keep; Keep leaves both batches","Delete shows \"No batches yet.\" and it is still shown after a reload"]}]},{"id":"API","title":"Generate and validate service","summary":"The same rules for scripts.","reqs":[{"id":"FR-API-01","weight":"Could","text":"The generate service enforces the same rules as the page and returns structured errors.","accept":["type vela, count 10, seed 42: 200 with 10 cards and seed 42","count 51: 422 {field:\"count\", message:\"You can generate at most 50 cards at a time\"}","type bogus: 422 \"Unknown card type \\\"bogus\\\"\""]}]}],"nfr":[],"glossary":[{"term":"Luhn checksum","meaning":"The check-digit rule every real card number must satisfy."},{"term":"Seed","meaning":"A number that makes random generation repeatable."},{"term":"Batch","meaning":"One click of Generate; it gets an id such as B-3."}],"outOfScope":["Real payment processing","Clipboard contents (browsers may block clipboard access)"]},{"ns":"echo","title":"Request Echo","version":"1.0","summary":"Request Echo is an HTTP request and response service for API testing. Its endpoints reflect the arguments, headers, body, form fields and cookies they receive, or produce a requested status code, delay, redirect chain, cookie or authentication challenge. A playground builds requests from method, path, query and header grids and several body types, and keeps saved requests per browser tab.","problem":"People who test HTTP clients need a service that answers exactly what was sent or exactly what was asked for. If an echo drops a value, a status is ignored or an auth check is lax, tests built on it give wrong confidence.","personas":[{"name":"API tester","need":"Send requests and trust that the response reflects them faithfully."},{"name":"Client developer","need":"Get predictable statuses, redirects, delays, cookies and auth challenges to exercise error handling."}],"goals":["Every echo endpoint reflects what was sent without loss or change.","Status, delay, redirect, cookie and auth endpoints behave exactly as requested.","The playground builds the same request the user sees in its grids.","Invalid input is caught before sending where possible."],"features":[{"id":"ECH","title":"Echo endpoints","summary":"Method endpoints and /anything reflect the request.","reqs":[{"id":"FR-ECH-01","weight":"Must","text":"GET /get answers 200 with a JSON body that reflects the query arguments, the URL and the request headers.","accept":["GET /get?keval=run-4821 returns 200 with Content-Type application/json and args.keval equal to \"run-4821\".","url ends with /api/echo/get?keval=run-4821 and headers contains Host."]},{"id":"FR-ECH-02","weight":"Must","text":"A query key that appears more than once is echoed as a list of all its values in order.","accept":["/get?keval=demo-123&tag=a&tag=b returns args.keval \"demo-123\" and args.tag [\"a\",\"b\"]."]},{"id":"FR-ECH-03","weight":"Must","text":"A JSON body is echoed parsed in \"json\" and raw in \"data\", with its method and headers.","accept":["POST {\"item\":\"Notebook\",\"qty\":3,\"gift\":false} returns json equal to that object.","The X-Request-Id header sent is echoed in headers; headers[\"Content-Type\"] is application/json; method \"POST\"."]},{"id":"FR-ECH-04","weight":"Should","text":"Urlencoded form bodies arrive in \"form\" and leave \"json\" null.","accept":["PUT with city=Lyon and zip=69001 returns form {\"city\":\"Lyon\",\"zip\":\"69001\"}, json null, method \"PUT\" and Content-Type application/x-www-form-urlencoded."]},{"id":"FR-ECH-05","weight":"Should","text":"Each method endpoint and /anything echo the method, body, arguments and URL; an endpoint that accepts only one method answers 404 to others.","accept":["DELETE /delete with {\"id\":7} returns 200, json {\"id\":7} and method \"DELETE\".","PATCH /anything/a/b?x=1 with text \"hi\" returns data \"hi\", args {\"x\":\"1\"} and a url ending /anything/a/b?x=1.","GET /post returns 404.","GET /base64/UmVxdWVzdCBFY2hvIHJvY2tz returns 200 with the text \"Request Echo rocks\"."]}]},{"id":"STA","title":"Status, redirects and delay","summary":"Endpoints produce a chosen status code, a redirect chain or a delayed answer.","reqs":[{"id":"FR-STA-01","weight":"Must","text":"The status endpoint answers exactly the requested code, and an invalid code is refused.","accept":["/status/201 shows \"201 Created\"; /status/404 shows \"404 Not Found\" with an empty body.","/status/418 shows \"418 I'm a teapot\" with ASCII art containing \"teapot\"; /status/503 shows \"503 Service Unavailable\".","/status/abc returns 400 with the message \"Invalid status code\"."]},{"id":"FR-STA-02","weight":"Should","text":"A redirect chain is followed to /get and the final response reports that it was redirected.","accept":["\"Redirect ×3\" ends 200 OK with \"Redirected → /api/echo/get\" and a body url ending /api/echo/get."]},{"id":"FR-STA-03","weight":"Should","text":"The delay endpoint waits the requested time before answering, and the client shows it is waiting.","accept":["The send button shows \"Sending…\" for about 2 seconds on a 2 second delay.","The answer is 200 OK and the displayed time is at least 2000 ms."]}]},{"id":"AUT","title":"Authentication challenges","summary":"Basic and Bearer endpoints check the credentials sent.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"The Basic auth endpoint accepts only the user and password named in its path; a wrong password or a missing Authorization header gives 401 with an empty body.","accept":["Correct credentials return 200 {\"authenticated\":true,\"user\":<the path user>}.","A wrong password returns 401 Unauthorized with an empty body.","Removing the Authorization header returns 401 Unauthorized."]},{"id":"FR-AUT-02","weight":"Should","text":"The Bearer endpoint echoes the token of a Bearer authorization and refuses any other scheme with 401.","accept":["Authorization \"Bearer <token>\" returns 200 {\"authenticated\":true,\"token\":<that token>}.","Authorization \"Token abc\" returns 401 Unauthorized."]}]},{"id":"COK","title":"Cookies and response headers","summary":"Endpoints set and delete cookies and set response headers from the query.","reqs":[{"id":"FR-COK-01","weight":"Should","text":"Cookies set through the cookie endpoints persist in the browser and can be deleted by name.","accept":["/cookies/set?flavor=oat&size=xl answers 200 after a redirect with cookies {\"flavor\":\"oat\",\"size\":\"xl\"}.","/cookies still lists flavor and size.","/cookies/delete?flavor leaves cookies {\"size\":\"xl\"}."]},{"id":"FR-COK-02","weight":"Could","text":"Response headers requested in the query are returned and visible in the response Headers tab.","accept":["/response-headers?x-trace=abc123 answers 200 OK and the Headers tab lists x-trace with value abc123."]}]},{"id":"PLY","title":"Playground","summary":"The playground builds requests from grids and body editors and saves them per tab.","reqs":[{"id":"FR-PLY-01","weight":"Must","text":"The Params grid and the Path stay in sync in both directions, with values correctly encoded and disabled rows left out.","accept":["Path /get?color=teal&size=9 shows Params badge 2 with rows color=teal and size=9.","Unticking size makes the Path /get?color=teal and the badge 1.","Adding key \"note\" with value \"a b&c\" makes the Path /get?color=teal&note=a%20b%26c; the response args is {\"color\":\"teal\",\"note\":\"a b&c\"}."]},{"id":"FR-PLY-02","weight":"Should","text":"Malformed JSON is refused before sending with a message starting \"Body is not valid JSON:\" and no response is shown; a valid body sends.","accept":["{\"a\": 1,} shows \"Body is not valid JSON:\" and no new response.","{\"a\": 1} returns 200 OK with json {\"a\":1}."]},{"id":"FR-PLY-03","weight":"Could","text":"GET sends no body: the body settings state this and are disabled.","accept":["The Body tab shows \"GET requests are sent without a body.\" and the body type radios are disabled."]},{"id":"FR-PLY-04","weight":"Could","text":"Saved requests persist across reloads for the tab, can be deleted and cannot reuse an existing name.","accept":["Saving \"Orders lookup\" lists it last among 9 saved requests.","Saving again as \"teapot\" shows \"A saved request named \\\"teapot\\\" already exists\".","After a reload \"Orders lookup\" is still listed; deleting it leaves 8."]}]},{"id":"FRM","title":"HTML order form and catalogue","summary":"A classic HTML order form posts to the echo, and a catalogue lists the endpoints.","reqs":[{"id":"FR-FRM-01","weight":"Must","text":"The HTML order form submits urlencoded fields to /api/echo/post, with repeated checkboxes arriving as a list.","accept":["Submitting shows JSON whose form has custname \"Rae Kim\", size \"medium\", topping [\"bacon\",\"mushroom\"], delivery \"19:30\" and comments \"Ring twice\"."]},{"id":"FR-FRM-02","weight":"Could","text":"The preferred delivery time must be between 11:00 and 21:00 in 15-minute steps; the browser blocks other values.","accept":["22:00 is blocked with a \"must be 21:00 or earlier\" style message.","19:10 is blocked because it is not on a 15-minute step."]},{"id":"FR-FRM-03","weight":"Could","text":"The endpoint catalogue lists every endpoint and can be filtered by group and search text.","accept":["The page reads \"21 endpoints\".","Group \"Auth\" shows \"2 endpoints\": /basic-auth/{user}/{passwd} and /bearer.","All groups with search \"cookie\" shows \"3 endpoints\"."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"An echo never drops, reorders or alters data it was sent.","accept":["Repeated keys, JSON bodies and form fields round-trip unchanged."]},{"id":"NFR-02","weight":"Should","text":"A slow response is awaited by the playground and is not treated as a failure."}],"glossary":[{"term":"Echo","meaning":"A response that reflects the request that was received."},{"term":"Saved request","meaning":"A playground request stored for the current browser tab."}],"outOfScope":["File uploads","Streaming and gzip endpoints"]},{"ns":"genome","title":"Tumor Model Explorer","version":"1.0","summary":"Tumor Model Explorer is a public research catalogue of 372 patient-derived childhood solid-tumour models described by 24 attributes. Researchers search and filter the catalogue in a grid that loads rows from the server in blocks of 100, sort it, export it to CSV, open a detail drawer for each model and submit and withdraw requests for models.","problem":"Researchers choose models for experiments from a very wide and long table. If the count, the rows, the sort or the export is wrong, they pick the wrong models or miss suitable ones. The catalogue must expose every model and attribute exactly once and answer every filter with exactly the right rows.","personas":[{"name":"Researcher","need":"Find models with specific diagnosis, age, sex, status and availability, export them and request material."},{"name":"Catalogue curator","need":"Be sure that what the grid, the count and the export show is the true catalogue."}],"goals":["The results count equals the number of rows the grid can show and the number of rows exported.","Every model appears exactly once and every attribute is visible.","Filters, search and sort return exactly the right rows and are reproducible from a link.","A request can be made for at most 10 models and withdrawn while still submitted."],"features":[{"id":"GRD","title":"Results grid","summary":"A virtualised grid shows all models with 24 attribute columns and a frozen sample-name column.","reqs":[{"id":"FR-GRD-01","weight":"Must","text":"The results count equals the number of distinct model rows the grid can show, including rows beyond the first server block.","accept":["The unfiltered catalogue reads \"372 results\" and exposes 372 rows with 372 distinct O-PDX Sample Names.","The last row in the default order is PXOS-4592_X1."]},{"id":"FR-GRD-02","weight":"Must","text":"The grid shows all 24 data columns in the catalogue order plus a Select column, with the sample name pinned on the left while scrolling horizontally.","accept":["Columns in order: O-PDX Sample Name, Disease, Age, Sex, Status, Location, Common Mutations, Treated, RMS subtype/fusion, Known Metastatic Site(s), NMYC Amplified, HLA-A.a1, HLA-A.a2, HLA-B.a1, HLA-B.a2, HLA-C.a1, HLA-C.a2, Germline Mutation, Matched Sequence (WGS) Available, Matched RNASeq available (O-PDX and Patient tumor), 3D-spheroid, 2D-adapted, Cell Line, iPSC."]},{"id":"FR-GRD-03","weight":"Must","text":"Age sorts numerically; clicking the Age header cycles ascending, descending and back to the default order, and the header shows the direction.","accept":["First click: ▲ with PXRB-8450_X1 (0.3) first.","Second click: ▼ with PXSS-8291_X2 (20.8), PXRHB-6855_X2 (20.8), PXSS-3842_X2 (20.7) first.","Third click: no arrow and the default order returns with PXSS-8614_X1 first."]},{"id":"FR-GRD-04","weight":"Could","text":"Filter and sort state is kept in the URL so a reload or a shared link reproduces the view; \"Clear all filters\" also clears the sort.","accept":["Ewing sarcoma ticked and Age ascending survive a reload: \"46 results\" and Age still ▲.","\"Clear all filters\" gives \"372 results\", no ticks and no sort."]}]},{"id":"FLT","title":"Facets, age range and search","summary":"Facets, an age range and free-text search narrow the catalogue.","reqs":[{"id":"FR-FLT-01","weight":"Must","text":"Facets from different groups combine with AND, and facet counts react to the other groups.","accept":["Disease Neuroblastoma gives \"58 results\" with Sex Male 33 and Female 25.","Adding Sex Female gives \"25 results\", every row Neuroblastoma and Female.","Osteosarcoma, Female and Relapse yields exactly 4 models: PXOS-0613_X3, PXOS-6135_X1, PXOS-6910_X1, PXOS-5922_X3."]},{"id":"FR-FLT-02","weight":"Must","text":"Several values of one facet are OR-ed and widen the result set.","accept":["Wilms tumor and Hepatoblastoma together give \"60 results\" (40 plus 20)."]},{"id":"FR-FLT-03","weight":"Should","text":"The age range is inclusive at both ends and can leave either end empty.","accept":["Min 5 and Max 10 give \"96 results\", every age from 5 to 10 inclusive.","Max 1 with Min empty gives \"9 results\", all Retinoblastoma or Neuroblastoma."]},{"id":"FR-FLT-04","weight":"Should","text":"An invalid age range is refused with a message instead of returning nothing.","accept":["Min 9 and Max 3 shows \"Minimum age cannot be greater than maximum age\".","Min \"abc\" shows \"Minimum age must be a number\"."]},{"id":"FR-FLT-05","weight":"Should","text":"Free-text search matches any column case-insensitively and an empty result says so.","accept":["\"alk\" gives \"24 results\", every row with ALK in Common Mutations.","\"zzzz\" gives \"0 results\" and \"No models match these filters.\""]},{"id":"FR-FLT-06","weight":"Should","text":"Values in columns the facets do not cover can be read accurately across all rows.","accept":["18 models have iPSC \"Yes\".","Rhabdomyosarcoma (\"64 results\") has Alveolar (PAX3-FOXO1) 15, Alveolar (PAX7-FOXO1) 17, Spindle cell (MYOD1) 11, Embryonal 10 and Embryonal (fusion-negative) 11."]}]},{"id":"EXP","title":"CSV export and detail drawer","summary":"The current result can be exported and each model opened in a drawer.","reqs":[{"id":"FR-EXP-01","weight":"Must","text":"Export CSV contains a header with the 24 column labels and every row matching the current filters, not only the first block of 100.","accept":["With Wilms tumor filtered (\"40 results\") the file has the header and 40 data rows.","With no filters the file has 372 data rows, matching \"372 results\"."]},{"id":"FR-EXP-02","weight":"Should","text":"Clicking a sample name opens a dialog listing the model's attributes, and Escape closes it.","accept":["PXOS-4592_X1 opens a dialog titled PXOS-4592_X1 listing 23 attributes including Osteosarcoma, 15.8, Male, Diagnosis, Pelvis, Germline Mutation TP53 and iPSC No.","Escape closes the dialog and shows the grid."]}]},{"id":"REQ","title":"Model requests","summary":"Researchers request up to 10 models and withdraw submitted requests.","reqs":[{"id":"FR-REQ-01","weight":"Must","text":"Selected models can be requested through a form; a submitted request appears first in My requests with status Submitted.","accept":["Selecting PXSS-8614_X1 and PXOS-4234_X2 enables \"Request selected (2)\", and the request page lists both.","Submitting with the material transfer terms accepted shows \"Request RQ-2 submitted\" with 2 models.","My requests lists RQ-2 first (Submitted, 2 models) above RQ-1 (Under review)."]},{"id":"FR-REQ-02","weight":"Should","text":"Every required request field reports its own message.","accept":["Empty submit: \"Enter your full name\", \"Enter your email address\", \"Enter your institution\", \"Describe the intended use in at least 20 characters\", \"You must accept the material transfer terms\".","Email \"rae@lab\" shows \"Enter a valid email address\".","With the model removed it shows \"Select at least one model\"."]},{"id":"FR-REQ-03","weight":"Should","text":"At most 10 models can be selected for one request.","accept":["Ten ticked rows give \"Request selected (10)\".","An eleventh shows \"You can select at most 10 models\", stays unticked and the button still says 10."]},{"id":"FR-REQ-04","weight":"Should","text":"Only a Submitted request can be withdrawn, after confirmation.","accept":["Withdraw on RQ-2 asks \"Withdraw RQ-2?\" with Withdraw and Keep; Withdraw sets the status to Withdrawn and removes its button.","RQ-1 (Under review) offers no Withdraw button."]}]},{"id":"API","title":"Search API","summary":"The API exposes the same data and rules as the grid.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The search API pages and filters like the grid and refuses unknown sorts, unknown models and over-sized requests.","accept":["/api/genome/search?offset=300&limit=100 returns 200 with total 372 and 72 rows.","/api/genome/search?disease=Neuroblastoma&sex=Female returns total 25.","sort=nope returns 422 \"Unknown sort column \\\"nope\\\"\".","/api/genome/model/NOPE returns 404 \"No model named \\\"NOPE\\\"\".","A request with 11 samples returns 422 with errors.samples \"You can request at most 10 models per request\"."]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Rows are loaded from the server in blocks of 100 as the user scrolls without losing, repeating or reordering any row.","accept":["The 372 rows seen across all blocks are 372 distinct models."]},{"id":"NFR-02","weight":"Should","text":"The catalogue is public and needs no sign-in."}],"glossary":[{"term":"Model","meaning":"A patient-derived tumour model identified by its O-PDX Sample Name."},{"term":"Facet","meaning":"A filter group with selectable values and counts."},{"term":"Withdrawn","meaning":"The status of a request cancelled by the researcher."}],"outOfScope":["Real specimen shipping","Authentication (the catalogue is public)"]},{"ns":"bank","title":"Harbor Mutual Bank","version":"5.0","summary":"Harbor Mutual Online Banking lets retail customers register, sign in and manage their accounts: see balances and activity, open accounts, move money between their own accounts, pay bills, search transactions, request loans and keep contact details current.","problem":"Customers need to self-serve everyday banking without visiting a branch, and must be able to trust that every figure on screen reconciles to the cent.","personas":[{"name":"Retail customer","need":"Check balances, move money and pay bills quickly, with clear confirmation of what happened."},{"name":"New customer","need":"Open an online profile and a funded first account in one step."},{"name":"Support agent","need":"Point customers to self-service recovery when they forget their login."}],"goals":["Every money movement records a matching debit and credit; no money is created or lost.","Totals always equal the sum of the parts they summarise.","Invalid input is rejected per field, before any money moves."],"features":[{"id":"REG","title":"Registration","summary":"Self-service sign-up that creates the profile and a first checking account.","reqs":[{"id":"FR-REG-01","weight":"Must","text":"The registration form requires every field and reports each missing one separately.","accept":["Submitting an empty form shows \"<Field> is required.\" for each of the 11 fields","Values already entered are kept when the form is shown again"]},{"id":"FR-REG-02","weight":"Must","text":"Password and Confirm must match.","accept":["A mismatch shows \"Passwords did not match.\" and no profile is created"]},{"id":"FR-REG-03","weight":"Must","text":"Usernames are unique, compared case-insensitively.","accept":["An existing username in any letter case shows \"This username already exists.\""]},{"id":"FR-REG-04","weight":"Must","text":"A successful registration signs the customer in and opens a checking account with the $515.50 welcome balance.","accept":["Confirmation \"Your account was created successfully. You are now logged in.\"","Accounts Overview lists exactly one account with Balance, Available and Total $515.50"]}]},{"id":"LOG","title":"Sign-in and login recovery","summary":"Customer login, logout and lookup of forgotten credentials.","reqs":[{"id":"FR-LOG-01","weight":"Must","text":"Failed sign-ins show a distinct error for missing input and for unknown credentials.","accept":["Empty fields: \"Please enter a username and password.\"","Wrong credentials: \"The username and password could not be verified.\""]},{"id":"FR-LOG-02","weight":"Must","text":"Signed-in customers see a welcome with their full name and the Account Services menu; Log Out returns to the public home page.","accept":["Menu items: Open New Account, Accounts Overview, Transfer Funds, Bill Pay, Find Transactions, Update Contact Info, Request Loan, Log Out"]},{"id":"FR-LOG-03","weight":"Should","text":"Customer Lookup returns the username and password when name, address, zip and SSN all match, and signs the customer in.","accept":["This plain-text reveal is accepted behaviour for this product version"]}]},{"id":"ACC","title":"Accounts overview and activity","summary":"Balances across all accounts and per-account transaction history.","reqs":[{"id":"FR-ACC-01","weight":"Must","text":"Accounts Overview lists every account with Balance and Available amount, and a Total row equal to the sum of all balances, including negative ones.","accept":["Available is lower than Balance when funds are on hold","An overdrawn account shows a negative Balance and Available $0.00"]},{"id":"FR-ACC-02","weight":"Should","text":"Account Details shows number, type, balance and available, and the activity list can be filtered by month and by Credit/Debit.","accept":["A filter with no matches shows \"No transactions found.\""]},{"id":"FR-ACC-03","weight":"Could","text":"Each activity row links to a Transaction Details page with ID, date, description, type and amount."}]},{"id":"OPN","title":"Open new account","summary":"Open a CHECKING or SAVINGS account funded from an existing one.","reqs":[{"id":"FR-OPN-01","weight":"Must","text":"A new account of the chosen type is opened with a $100.00 minimum deposit and its number is shown as a link."},{"id":"FR-OPN-02","weight":"Must","text":"The deposit is debited from the funding account and credited to the new one, so the Total is unchanged.","accept":["The new account shows one Credit \"Funds Transfer Received\" $100.00 dated today"]},{"id":"FR-OPN-03","weight":"Must","text":"Opening is refused when the funding account cannot cover $100.00.","accept":["Message \"Account could not be opened: account <n> does not have the $100.00 minimum deposit.\"","No account is created and balances do not change"]}]},{"id":"TRF","title":"Transfer funds","summary":"Move money between the customer’s own accounts.","reqs":[{"id":"FR-TRF-01","weight":"Must","text":"The amount must be a positive number with at most two decimals.","accept":["Empty, non-numeric, negative, zero or 3-decimal amounts show \"Please enter a valid amount.\" and move nothing"]},{"id":"FR-TRF-02","weight":"Must","text":"A transfer debits the source and credits the target by the same amount and records both sides in activity.","accept":["Confirmation \"$<amount> has been transferred from account #<from> to account #<to>.\"","Source gains Debit \"Funds Transfer Sent\", target gains Credit \"Funds Transfer Received\", both dated today","The Total is unchanged"]},{"id":"FR-TRF-03","weight":"Should","text":"Transfers may exceed the source balance (overdraft is allowed); Available then shows $0.00."},{"id":"FR-TRF-04","weight":"Could","text":"A transfer to the same account is accepted and records a matching debit and credit with net zero."}]},{"id":"PAY","title":"Bill pay","summary":"Pay an external payee from one of the customer’s accounts.","reqs":[{"id":"FR-PAY-01","weight":"Must","text":"Payee name, address, city, state, zip, phone, account, verify account and amount are required; account numbers must be numeric and the amount valid.","accept":["Each failing field shows its own message"]},{"id":"FR-PAY-02","weight":"Must","text":"Account # and Verify Account # must match before anything is paid.","accept":["A mismatch shows \"The account numbers do not match.\" and no debit occurs"]},{"id":"FR-PAY-03","weight":"Must","text":"A valid payment debits the chosen account and records \"Bill Payment to <payee>\".","accept":["Confirmation \"Bill Payment to <payee> in the amount of $<amount> from account <n> was successful.\""]}]},{"id":"FND","title":"Find transactions","summary":"Search one account’s history by ID, date, date range or amount.","reqs":[{"id":"FR-FND-01","weight":"Should","text":"Each criterion has its own Find button and searches only the selected account.","accept":["A transaction of another account is never returned"]},{"id":"FR-FND-02","weight":"Should","text":"Dates use MM-DD-YYYY and amounts must be valid numbers; invalid criteria show a message instead of results.","accept":["\"Please enter a valid date.\" / \"Please enter a valid amount.\"","No match shows \"No transactions found.\""]}]},{"id":"LON","title":"Request loan","summary":"Instant loan decision funded into a new loan account.","reqs":[{"id":"FR-LON-01","weight":"Must","text":"A loan is approved only when the down payment is at least 10% of the loan amount and covered by the chosen account’s balance."},{"id":"FR-LON-02","weight":"Must","text":"An approved loan opens a new account holding the loan amount and debits the down payment from the chosen account."},{"id":"FR-LON-03","weight":"Must","text":"A denied loan shows Status Denied with the reason and changes no balances."}]},{"id":"UPD","title":"Contact info","summary":"Keep the customer’s address and phone current.","reqs":[{"id":"FR-UPD-01","weight":"Could","text":"Update Contact Info is pre-filled with the current profile, requires every field and confirms a successful save."}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Money is shown with a $ sign, thousands separators and exactly two decimals; negative amounts are prefixed with \"-\"."},{"id":"ERR-01","weight":"Must","text":"Account Services pages require a signed-in session; signed-out visits return to the home page with the login form."},{"id":"ERR-03","weight":"Should","text":"Unknown or malformed account and transaction ids show the generic error page \"An internal error has occurred and has been logged.\""}],"glossary":[{"term":"Available","meaning":"Balance minus holds; never below $0.00."},{"term":"Hold","meaning":"A pending deposit that counts toward Balance but not Available."}],"outOfScope":["Payments to real networks","Branch or admin consoles","Performance under load"]},{"ns":"inn","title":"Willow Hollow B&B","version":"1.8","summary":"Willow Hollow B&B is a guesthouse website. Visitors browse rooms, choose a stay by dragging across a month calendar, book with their details and send the owner a message; the owner signs in to an admin panel to manage rooms, see bookings on a report calendar, read messages and change the branding shown publicly.","problem":"A small guesthouse needs bookings that can never double-book a room, honest validation for guests and a simple back office whose changes show up on the public page immediately.","personas":[{"name":"Guest","need":"Pick dates, see the price and book a free room in one pass; ask the owner a question."},{"name":"Owner / admin","need":"Keep rooms, bookings, messages and branding up to date."}],"goals":["A room is never booked twice for the same night, yet a stay may begin on the day the previous guest leaves.","Every invalid input is reported per field, before anything is stored.","Admin changes (rooms, branding, deletions) are reflected everywhere they are used."],"features":[{"id":"FRT","title":"Front page","summary":"Branding, rooms and room features.","reqs":[{"id":"FR-FRT-01","weight":"Must","text":"The front page shows the guesthouse name and description and every room in room-number order with its price per night.","accept":["Heading \"Willow Hollow B&B\" with the \"family-run bed and breakfast\" description","Five cards: Single $95, Double $150, Suite $225, Twin $120, Family $190, each \"per night\"","The accessible (wheelchair) icon appears on the Double and Family cards only"]},{"id":"FR-FRT-02","weight":"Could","text":"Each room card shows a badge for exactly the features configured for the room.","accept":["Suite: WiFi, TV, Radio, Refreshments, Safe, Views","Twin: WiFi, Views"]}]},{"id":"BKG","title":"Booking","summary":"Drag-to-select stay dates and guest details.","reqs":[{"id":"FR-BKG-01","weight":"Must","text":"A stay is chosen by pressing on the check-in day and dragging to the check-out day; the summary shows the dates, the number of nights and the total price (nights × nightly price).","accept":["Twin, 3 → 6 May 2027: \"Check-in Mon 3 May 2027 · Check-out Thu 6 May 2027 · 3 nights · $360 total\"","Single, 13 → 15 March 2027: \"2 nights · $190 total\""]},{"id":"FR-BKG-02","weight":"Must","text":"A valid guest books the stay and receives a confirmation; the nights become Unavailable in the calendar and the check-out day stays free.","accept":["Dialog \"Booking Successful!\" — \"Your booking has been confirmed for: 2027-05-03 - 2027-05-06\"","Reopened calendar: days 3, 4 and 5 Unavailable, day 6 free"]},{"id":"FR-BKG-03","weight":"Must","text":"Existing bookings show their nights as Unavailable; the check-out day is not a booked night.","accept":["Single, March 2027: days 10, 11, 12, 20 and 21 Unavailable; days 13 and 22 are not"]},{"id":"FR-BKG-04","weight":"Must","text":"A stay overlapping any booked night of the room — including the booking’s last night — is refused and nothing is stored.","accept":["Single, 11 → 14 March and 12 → 14 March 2027: error \"The room dates are either invalid or are already booked\"; no success dialog"]},{"id":"FR-BKG-05","weight":"Should","text":"A stay may begin on the day a previous guest checks out.","accept":["Single, 13 → 15 March 2027 books successfully for 2027-03-13 - 2027-03-15"]},{"id":"FR-BKG-06","weight":"Should","text":"A single-day click selects zero nights and cannot be booked.","accept":["Summary \"No nights selected — drag from check-in day to check-out day\"","Booking shows \"Drag across the calendar to choose at least one night\""]},{"id":"FR-BKG-07","weight":"Should","text":"Stays starting before today are refused with the same dates error.","accept":["Dragging 10 → 12 in the previous month: days greyed as past, \"2 nights\"; Book shows \"The room dates are either invalid or are already booked\""]},{"id":"FR-BKG-08","weight":"Should","text":"Guest fields are required and length-checked, with one message per invalid field.","accept":["Blank: \"Firstname must not be blank\", \"Lastname must not be blank\", \"Email must not be blank\", \"Phone must not be blank\"","Firstname 3–18, lastname 3–30 and phone 11–21 characters; \"Al\" and a 10-digit phone give \"Firstname must be between 3 and 18 characters.\" and \"Phone must be between 11 and 21 characters.\"","\"Bea\" with an 11-digit phone books successfully"]},{"id":"FR-BKG-09","weight":"Could","text":"Cancel closes the booking panel on the card without booking and restores \"Book this room\"."}]},{"id":"CNT","title":"Contact form","summary":"Messages from guests to the owner.","reqs":[{"id":"FR-CNT-01","weight":"Must","text":"A valid message thanks the sender by name and echoes the subject.","accept":["\"Thanks for getting in touch Nora Vance!\" then \"We'll get back to you about Parking for two cars as soon as possible.\""]},{"id":"FR-CNT-02","weight":"Should","text":"An empty form lists all five required-field errors at once.","accept":["\"Name may not be blank\", \"Email may not be blank\", \"Phone may not be blank\", \"Subject may not be blank\", \"Message may not be blank\""]},{"id":"FR-CNT-03","weight":"Should","text":"Subject must be 5–100 characters and message 20–2000.","accept":["\"Help\" and a 19-character message give \"Subject must be between 5 and 100 characters.\" and \"Message must be between 20 and 2000 characters.\"","\"Help!\" and a 20-character message are accepted"]},{"id":"FR-CNT-04","weight":"Must","text":"A submitted message arrives in the admin inbox as unread and raises the unread badge.","accept":["After one new message the badge shows 3 and \"Nora Vance — Parking for two cars\" is the first, bold row"]}]},{"id":"ADM","title":"Admin sign-in","summary":"Access control for the back office.","reqs":[{"id":"FR-ADM-01","weight":"Must","text":"The admin signs in with valid credentials; wrong credentials are refused and the fields outlined.","accept":["Wrong credentials: \"Invalid credentials\" and both fields outlined red","Success: navbar Rooms, Report, Branding, Messages (unread badge), Front Page, Logout; rooms 101–105 listed"]},{"id":"FR-ADM-02","weight":"Should","text":"Admin pages require a session: logged-out visits land on the login form and Logout ends the session.","accept":["Opening #/admin/report signed out redirects to #/admin with \"Log into your account\"","After Logout, #/admin/messages shows the login form"]}]},{"id":"ROM","title":"Room management","summary":"Create, edit and delete rooms.","reqs":[{"id":"FR-ROM-01","weight":"Must","text":"A created room appears in the admin list and on the front page with its price and feature badges.","accept":["Room 106, Double, not accessible, price 140, WiFi + TV: the list row reads \"106 · Double · false · 140 · WiFi, TV\"; the front page has a sixth card Double $140"]},{"id":"FR-ROM-02","weight":"Should","text":"Room creation is validated with itemised messages: room number required and unique, price a whole number from 1 to 999.","accept":["Empty: \"Room name must be set\" and \"must be greater than or equal to 1\"","Duplicate 101: \"Room 101 already exists\"","Price 1000: \"must be less than or equal to 999\"; 12.5: \"Price must be a whole number\""]},{"id":"FR-ROM-03","weight":"Must","text":"Both price limits are valid: 1 and 999 save.","accept":["Rooms 201 (price 1) and 202 (price 999) are created"]},{"id":"FR-ROM-04","weight":"Could","text":"Editing a room updates the admin view and the front page.","accept":["Room 104 set to price 130 with TV added: details \"WiFi, TV, Views\"; the Twin card shows $130 and WiFi, TV, Views badges"]},{"id":"FR-ROM-05","weight":"Must","text":"Deleting a room removes it and all its bookings everywhere, including the report calendar.","accept":["Deleting room 103 leaves four rooms and no \"Tomas Lindqvist\" bar on 2–5 April 2027"]}]},{"id":"RPT","title":"Report and booking edits","summary":"Owner’s calendar of all bookings.","reqs":[{"id":"FR-RPT-01","weight":"Should","text":"The report calendar draws each booking as a bar across its nights labelled with the guest name; clicking a bar shows the booking.","accept":["March 2027: \"Jules Ferro\" 10–12, \"Priya Nandakumar\" 12–14, \"Ada Okafor\" 20–21","Jules Ferro dialog: Room 101, \"2027-03-10 - 2027-03-13 (3 nights)\", Deposit paid: true"]},{"id":"FR-RPT-02","weight":"Should","text":"A booking can be edited from its room page, but an edit that overlaps another booking is refused and the booking is unchanged.","accept":["Priya’s check-out changed to 2027-03-16 is saved","Jules Ferro’s check-out changed to 2027-03-21 shows \"The room dates are either invalid or are already booked\"; it stays 2027-03-10 → 2027-03-13"]},{"id":"FR-RPT-03","weight":"Should","text":"Deleting a booking removes it from the room page and the report and frees its nights for guests.","accept":["After deleting Ada Okafor’s booking only Jules Ferro remains for room 101 and days 20 and 21 are no longer Unavailable"]}]},{"id":"MSG","title":"Messages inbox","summary":"Reading and clearing guest messages.","reqs":[{"id":"FR-MSG-01","weight":"Must","text":"The inbox lists messages newest first with unread ones in bold; the navbar badge equals the number of unread messages and drops when a message is opened.","accept":["Seed: Rafael Costa, Mei Tanaka, Hollis Grant; Rafael and Hollis bold; badge 2","Opening Rafael Costa shows phone 07700900777, email and message; after closing it is no longer bold and the badge reads 1"]},{"id":"FR-MSG-02","weight":"Should","text":"Deleting a message removes its row and updates the badge.","accept":["With an unread new message the badge goes from 3 to 2 after deleting it"]}]},{"id":"BRN","title":"Branding","summary":"Public name and contact details.","reqs":[{"id":"FR-BRN-01","weight":"Must","text":"Saved branding replaces the public name and contact details on the front page.","accept":["Dialog \"Branding updated!\"","Name \"Willow Hollow Guesthouse\" becomes the front-page heading; contact phone 01234500000 appears on the contact card"]},{"id":"FR-BRN-02","weight":"Could","text":"Invalid branding is refused with one message per field and no success dialog.","accept":["Name \"AB\": \"Name must be between 3 and 100 characters.\"","Latitude 95: \"Latitude must be a number between -90 and 90\"","Email \"stay-at-home\": \"must be a well-formed email address\""]}]},{"id":"API","title":"Booking and admin API","summary":"REST access to rooms, bookings and reports.","reqs":[{"id":"FR-API-01","weight":"Should","text":"Public endpoints (rooms, bookings) are open; admin endpoints require a bearer token; overlaps are rejected with 409.","accept":["GET /api/inn/rooms: 200 with five rooms","GET /api/inn/report without a token: 401","With a token: 200 with four bookings ordered by check-in","POST /api/inn/bookings for room 1, 2027-03-11 → 2027-03-12: 409 \"The room dates are either invalid or are already booked\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Security: admin pages and admin API calls are inaccessible without a signed-in session or token."},{"id":"NFR-02","weight":"Should","text":"Data format: dates are shown as YYYY-MM-DD in confirmations and reports; prices are whole dollar amounts."}],"glossary":[{"term":"Night","meaning":"A day on which a guest sleeps in the room; the check-out day is not a night."},{"term":"Unavailable","meaning":"Calendar state of a night that is already booked."}],"outOfScope":["Payments and deposits","Email notifications","Map rendering"]},{"ns":"typing","title":"Tempo Typing","version":"3.4","summary":"Tempo Typing is a minimalist, keyboard-first typing test. People type against time, a word count, a quote or custom text, see an honest scoring breakdown, and — when signed in — keep a history, personal bests, a streak and a place on weekly and all-time leaderboards protected by server-side anti-cheat.","problem":"A typing score is only worth sharing if it cannot be faked and is computed consistently; every figure on the result screen and on a leaderboard must follow from what was actually typed.","personas":[{"name":"Practising typist","need":"A distraction-free test with accurate wpm, raw and accuracy, and control over difficulty and confidence rules."},{"name":"Competitor","need":"Personal bests and leaderboards that only contain legitimate results."},{"name":"Guest","need":"Try the test without an account and keep results on the device."}],"goals":["Scoring follows one formula everywhere: the UI, the saved result and the API agree.","Difficulty, confidence and paste rules are enforced as documented.","Only valid, long-enough, non-zen, non-failed results are saved and ranked."],"features":[{"id":"ENG","title":"Typing engine and scoring","summary":"How keystrokes become a result.","reqs":[{"id":"FR-ENG-01","weight":"Must","text":"A test on custom text ends on the last correct letter (no trailing space needed) and reports a full breakdown.","accept":["Typing \"The quick brown fox jumps over the lazy dog\" exactly ends the test as the final \"g\" is typed","Result shows acc 100%, characters 35/0/0/0 (correct/incorrect/extra/missed), test type \"custom english\", and wpm equal to raw","A guest sees \"Saved on this device. Sign in to keep your history.\""]},{"id":"FR-ENG-02","weight":"Must","text":"Wrong and extra letters are counted in separate buckets, mark the word as wrong, and lower accuracy; net wpm counts only correct letters and correct spaces, so with errors wpm is lower than raw.","accept":["Typing \"Thx quickk brown fox jumps over the lazy dog\" gives characters 34/1/1/0 and acc 91% (title \"90.91% (40 correct / 44 keypresses)\")","wpm = (correct letters + correct spaces) / 5 per minute; raw = (all typed letters + spaces) / 5 per minute; acc = correct keypresses / total keypresses"]},{"id":"FR-ENG-03","weight":"Must","text":"Pasting into the test is blocked: clipboard text types nothing and does not start the timer.","accept":["Toast \"Pasting is disabled\"; no letters are coloured"]},{"id":"FR-ENG-04","weight":"Should","text":"With confidence mode off, Backspace on an empty word re-opens the previous word only if it was incorrect.","accept":["After \"Thx\", space, Backspace twice and \"e\" the first word reads correct","A correct previous word cannot be re-opened","The wrong keypress still counts against accuracy (below 100%)"]},{"id":"FR-ENG-05","weight":"Could","text":"Clicking away blurs the words and shows \"Click here or press any key to focus\"; pressing a key refocuses and types it."},{"id":"FR-ENG-06","weight":"Could","text":"\"Repeat test\" replays the identical word list and labels the type \"words 10 english (repeated)\"."},{"id":"FR-ENG-07","weight":"Could","text":"\"Practice missed words\" builds a test from only the words typed wrong; its result is not saved.","accept":["After a run with errors in \"The\" and \"quick\" the practice test is exactly \"The quick\"","Save line \"Practice tests are not saved\""]}]},{"id":"DIF","title":"Difficulty and confidence","summary":"Rules that make a test stricter.","reqs":[{"id":"FR-DIF-01","weight":"Must","text":"Expert difficulty fails the test the moment an incorrect word is submitted with space; failed tests are not saved.","accept":["Typing \"Thx\" does not fail yet; pressing space shows \"test failed\" and \"Expert: an incorrect word was submitted\"","Save line \"Failed tests are not saved\""]},{"id":"FR-DIF-02","weight":"Should","text":"Master difficulty fails on the first incorrect key, without waiting for space.","accept":["Typing \"Tx\" shows \"test failed\" and \"Master: an incorrect key was pressed\""]},{"id":"FR-DIF-03","weight":"Should","text":"Confidence \"on\" makes a space final: after it, even a wrong word cannot be revisited, while Backspace still works inside the current word.","accept":["A \"confidence on\" badge is shown; \"Thx\", space, Backspace changes nothing","\"qa\" then Backspace leaves \"q\""]},{"id":"FR-DIF-04","weight":"Must","text":"Confidence \"max\" disables every Backspace, including Ctrl+Backspace.","accept":["A \"confidence max\" badge is shown; after \"Thx\", Backspace and Ctrl+Backspace leave \"Thx\" with the x still incorrect"]}]},{"id":"MOD","title":"Modes and configuration","summary":"Time, words, quote, zen and custom tests.","reqs":[{"id":"FR-MOD-01","weight":"Must","text":"A time test counts the timer down and ends on its own at exactly the chosen duration, adding words as the user types.","accept":["Time 15 shows the timer 15 → 1, then a result with time 15s and test type \"time 15 english\""]},{"id":"FR-MOD-02","weight":"Should","text":"Zen mode has no target text: Shift+Enter finishes it, accuracy shows \"-\", there is no \"practice missed words\", and the result is never saved.","accept":["Hint \"start typing — shift + enter to finish\"","Save line \"Zen results are not saved\""]},{"id":"FR-MOD-03","weight":"Should","text":"Quote mode in a language without quotes shows a notice and uses English quotes; the result shows the quote source.","accept":["Toast \"No quotes available for spanish — showing english quotes\"","Test type \"quote short english\" and a \"source\" stat"]},{"id":"FR-MOD-04","weight":"Could","text":"A custom time accepts whole numbers 1–3600 only.","accept":["0 and 3601 show \"Enter a whole number between 1 and 3600\"","45 closes the dialog and the button reads \"45\""]},{"id":"FR-MOD-05","weight":"Could","text":"Custom text cannot be empty and its limit is validated; text cycles up to the word limit.","accept":["Empty text: \"Text cannot be empty\"","Word limit 0: \"Word limit must be 1–1000 words\"","\"a b c\" with word limit 7 shows \"a b c a b c a\""]}]},{"id":"CMD","title":"Command line, themes and settings","summary":"Keyboard control and personalisation.","reqs":[{"id":"FR-CMD-01","weight":"Should","text":"Esc opens a fuzzy-search \"Command line\"; highlighting a theme previews it, Esc reverts and Enter applies and saves it.","accept":["\"thmemb\" highlights \"Theme: ember\" and previews its colours","Esc restores the previous colours; \"theme ember\" + Enter keeps ember and settings shows it selected"]},{"id":"FR-CMD-02","weight":"Could","text":"With quick restart set to \"esc\", Esc restarts the test with fresh words and Ctrl+Shift+P opens the command line.","accept":["Hints read \"esc restart test\" and \"ctrl + shift + p command line\""]},{"id":"FR-CMD-03","weight":"Could","text":"\"reset settings\" asks \"Are you sure?\" with \"yes, reset\" and \"cancel\" before restoring defaults (theme carbon, caret |)."},{"id":"FR-CMD-04","weight":"Should","text":"Signing in applies the account’s saved settings over the local defaults.","accept":["A fresh tab shows the carbon theme (dark grey, yellow accent); after sign-in the seeded account’s lagoon theme (teal) is applied and highlighted in settings"]}]},{"id":"AUT","title":"Accounts and sign-in","summary":"Sign-in with email or username.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Email or username plus password signs in; wrong credentials and empty fields show distinct errors.","accept":["Wrong password: an error alert and the sign-in page stays","Empty fields: \"Enter your email or username and password\"","Correct credentials open the account page"]},{"id":"FR-AUT-02","weight":"Could","text":"Signed-out results are kept on the device and listed on the guest account page, where \"clear local history\" empties them.","accept":["Heading \"guest\" and one row in \"local result history\"; after clearing \"No results yet.\""]}]},{"id":"ACC","title":"Account profile and history","summary":"Stats, personal bests and result history.","reqs":[{"id":"FR-ACC-01","weight":"Must","text":"The profile header reflects the seeded history: join date, tests started, tests completed, time typing and current streak.","accept":["Seeded account: \"joined 14 Feb 2026\", tests started 41, tests completed 12, time typing 00:07:12, current streak 3 days"]},{"id":"FR-ACC-02","weight":"Must","text":"Personal best tables show the best result per exact length; untouched lengths show \"-\".","accept":["time: 15 s 93 (98%), 30 s 88, 60 s 84, 120 s 76; tooltip on 15 s starts \"92.80 wpm · 94.40 raw · 98.31% acc\"","words: 10 → 89, 25 → 91, 50 → 83, 100 → \"-\""]},{"id":"FR-ACC-03","weight":"Should","text":"History lists results newest first, ten at a time, with \"load more\" and a mode filter; personal-best results carry a ♛ mark.","accept":["\"10 of 12\"; first row words 10 at 89.19 wpm; \"load more\" shows 12 rows, the last being custom at 63.82 wpm","Filter \"quote\": one row, 77.14 wpm \"quote medium\"","Filter \"words\": three rows (89.19, 90.70, 83.13) each with ♛"]},{"id":"FR-ACC-04","weight":"Must","text":"A signed-in, completed time test is saved and the first result for a configuration is announced as a new personal best.","accept":["A new account’s first time 15 test shows \"♛ New personal best!\"","Account page then shows tests completed 1 and the 15-second PB equal to that wpm"]},{"id":"FR-ACC-05","weight":"Should","text":"A test shorter than 15 seconds and with fewer than 10 words is not saved.","accept":["Nine words typed in under 15 seconds: \"Test too short — result not saved\"; history total stays 12"]}]},{"id":"LDB","title":"Leaderboards","summary":"All-time and weekly rankings for time 15 and time 60.","reqs":[{"id":"FR-LDB-01","weight":"Should","text":"All-time boards rank each account’s best english result for time 15 and time 60; accounts barred from leaderboards are excluded.","accept":["Time 15: top entry 148.20 wpm; the signed-in account is #8 with 92.80 (\"Your rank: #8\"), above an 88.04 entry","A barred account’s 152 wpm result is not listed","Time 60: top entry 126.30; the signed-in account #5 with 83.80"]},{"id":"FR-LDB-02","weight":"Must","text":"Weekly boards only include results from the last 7 days.","accept":["Time 15 weekly: top entry 139.65; the 21-day-old 148.20 and the other all-time leader are gone; signed-in account #5 (92.80)","Time 60 weekly: top entry 121.75; signed-in account #3 with 77.20 because its 83.80 best is 9 days old"]}]},{"id":"API","title":"Results and settings API","summary":"Server-side re-scoring and validation.","reqs":[{"id":"FR-API-01","weight":"Must","text":"POST /api/typing/results re-scores every submission from its breakdown and rejects values that do not follow, a duration that does not match the time mode, zen results and any wpm above 350.","accept":["The documented example body returns 201 with result.wpm 134.4","wpm altered to 180: 400 \"Result failed anti-cheat validation\"","testDuration 30 for time 15: 400 \"Test duration does not match the selected time\"","mode \"zen\": 400 \"Zen results are never saved\"","GET /api/typing/leaderboards/30: 404 \"Leaderboards exist for time 15 and time 60 only\""]},{"id":"FR-API-02","weight":"Could","text":"PUT /api/typing/settings validates keys and values and stores accepted ones.","accept":["{\"theme\":\"neon\"}: 400 Invalid value for \"theme\"","{\"fontSize\":3}: 400 Unknown setting \"fontSize\"","{\"theme\":\"ember\"} is returned by the next GET"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Security: results cannot be forged — saved wpm, raw and accuracy are always recomputed on the server and paste into the test is blocked."},{"id":"NFR-02","weight":"Could","text":"Accessibility: the whole test, restart and command line are operable from the keyboard alone."}],"glossary":[{"term":"wpm","meaning":"Net words per minute: (correct letters + correct spaces) / 5 per minute."},{"term":"raw","meaning":"Words per minute counting every typed letter and space, right or wrong."},{"term":"PB","meaning":"Personal best, tracked per exact configuration (mode, length, language, modifiers)."},{"term":"Confidence mode","meaning":"Setting that limits correcting earlier input: off, on (space is final) or max (no backspace)."}],"outOfScope":["Sign-up and password reset","Sound output quality","Exact wpm of human-typed runs"]},{"ns":"splitter","title":"Fair Share","version":"1.0","summary":"Fair Share splits restaurant and shopping bills. A user uploads a receipt photo (or picks a sample) and the parser extracts items, tax, tip and discounts. The user edits items, adds people, assigns items whole, shared or by unit, and gets per-person totals with proportional tax and tip and exact-cent rounding, then copies or shares a read-only link. Optional accounts add bill history, groups with running balances, debt simplification, recurring drafts, multi-currency expenses and sandbox settlement payments with fees, refunds and requests.","problem":"Splitting a bill by hand is error-prone: tax and tip must follow what each person ordered, cents must not vanish, and groups that share expenses over time need to know who owes whom and settle it with the fewest payments.","personas":[{"name":"Casual splitter","need":"Photograph a receipt, say who had what, and get fair totals without signing up."},{"name":"Group member","need":"Track shared expenses in a group, see a balance and settle it with the fewest payments."},{"name":"Group admin","need":"Manage members and invites for a household or trip group."}],"goals":["Per-person totals always add up to the bill to the cent.","Tax and tip follow each person's share of the items.","Group balances net to zero and can be settled with the fewest transfers; payments and refunds keep the ledger correct."],"features":[{"id":"RCP","title":"Receipt reading","summary":"Photo upload, sample receipts and failure handling.","reqs":[{"id":"FR-RCP-01","weight":"Must","text":"A receipt is read into items, tax, tip and a total check.","accept":["Choosing the \"Harbor Grill — dinner for three\" sample and \"Use sample\" shows \"Reading your receipt…\", then the Items step","Margherita pizza 1 × 16.50, Caesar salad 1 × 11.25, Craft beer 3 × 7.00, Truffle fries 1 × 8.75, Sparkling water 2 × 3.50","Tax 5.72, tip 0, \"✓ Matches the receipt total $70.22\""]},{"id":"FR-RCP-02","weight":"Should","text":"A receipt in another currency keeps its currency, and a service charge is read as the tip.","accept":["\"Café Lumière — euro receipt with service\": symbol €","Croque-monsieur 12.50, Café crème 2 × 3.80, Tarte Tatin 7.90 (subtotal €28.00); tax 2.80, tip 2.80; matches the receipt total €33.60"]},{"id":"FR-RCP-03","weight":"Should","text":"The receipt check flags a total that does not match the printed one and clears when the line is corrected.","accept":["\"Corner Market — groceries with a member discount\": badge \"⚠ … off by $0.45\"","Price of Sourdough 6.49 + Enter: \"✓ Matches the receipt total\""]},{"id":"FR-RCP-04","weight":"Should","text":"An unreadable photo is rejected with a clear message and recovery options.","accept":["\"A blurry photo (not readable)\": \"We couldn't find a receipt in this photo. Try a sharper photo, or enter the items yourself.\"","Buttons \"Try another photo\" and \"Enter items manually\", no Retry; manual entry opens an empty Items step with \"New item name\""]},{"id":"FR-RCP-05","weight":"Could","text":"A non-image file is refused with \"Unsupported file type. Use a JPG, PNG, HEIC or WebP photo.\""},{"id":"FR-RCP-06","weight":"Could","text":"A temporary reading failure keeps the photo and can be retried.","accept":["The first attempt fails with an error and a \"Retry\" button; Retry reads the receipt and opens Items"]}]},{"id":"ITM","title":"Items, tax and tip","summary":"Editing the bill lines, tax and tip.","reqs":[{"id":"FR-ITM-01","weight":"Should","text":"Quantity is a whole number from 1 to 99 and a negative price is allowed only on a Discount line.","accept":["Quantity 0, 100 or 2.5: \"Quantity must be a whole number from 1 to 99\"","Quantity 99 on Craft beer gives a line total of 693.00","Price -2: \"Price can't be negative — tick “Discount” for a discount line\""]},{"id":"FR-ITM-02","weight":"Should","text":"A bill can be entered by hand, including discount lines that lower the total.","accept":["Soup 2 × 6.00 and Bread 1 × 3.00: bill total $15.00","Coupon 5.00 with Discount ticked: bill total $10.00"]},{"id":"FR-ITM-03","weight":"Should","text":"Tip percent must be between 0 and 100 and the toggle switches to a fixed tip amount.","accept":["101: \"Tip must be between 0 and 100%\"","Switching to $ and typing 10 gives bill total $80.22 on Harbor Grill"]},{"id":"FR-ITM-04","weight":"Must","text":"A percentage tip is calculated on the pre-tax subtotal, not on subtotal plus tax.","accept":["Harbor Grill with the \"18\" chip: tip $11.61 and bill total $81.83"]},{"id":"FR-ITM-05","weight":"Could","text":"Tax more than twice the subtotal shows a warning but does not block.","accept":["Tax 200.00 shows a warning about unusually high tax; the total still updates"]}]},{"id":"PPL","title":"People and assignment","summary":"Adding people and assigning items whole, shared or by unit.","reqs":[{"id":"FR-PPL-01","weight":"Should","text":"Names are unique case-insensitively and at most 30 characters.","accept":["Ana then ana: \"Ana is already on this bill\"; only one Ana","A 31-character name is refused"]},{"id":"FR-PPL-02","weight":"Could","text":"Removing a person who has items asks for confirmation in an in-page dialog.","accept":["Removing Cleo opens a dialog (not a browser popup); Cancel keeps her and her items"]},{"id":"FR-PPL-03","weight":"Should","text":"The unassigned counter drops to zero as items are assigned, and per-unit assignment shows how many units are assigned.","accept":["Assign starts with \"5 unassigned\"","Craft beer \"By unit\" with Ana 2 and Ben 1 shows \"3 of 3 assigned\"","All items assigned shows \"All items assigned\""]},{"id":"FR-PPL-04","weight":"Could","text":"Results are blocked while nothing is assigned.","accept":["\"Nothing is assigned yet…\" followed by the five item names"]}]},{"id":"RES","title":"Results and rounding","summary":"Per-person totals with proportional tax and tip.","reqs":[{"id":"FR-RES-01","weight":"Must","text":"Tax and tip are spread in proportion to each person's item total, and the per-person totals add up to the bill.","accept":["Harbor Grill, 18% tip; Margherita pizza to Ana + Ben, Caesar salad to Cleo, Craft beer by unit Ana 2 and Ben 1, Truffle fries to everyone, Sparkling water to Cleo","Ana $31.93, Ben $23.05, Cleo $26.85; grand total $81.83 and sum of people $81.83","Ana's card: items 25.17, tax 2.23, tip 4.53"]},{"id":"FR-RES-02","weight":"Must","text":"Rounding never loses or invents cents: the leftover cent goes to one person and the people always sum to the bill.","accept":["A $10.00 item shared by three: one person $3.34, two $3.33, sum $10.00 equals the grand total"]}]},{"id":"SHR","title":"Sharing","summary":"Read-only share links.","reqs":[{"id":"FR-SHR-01","weight":"Should","text":"A share link opens the split read-only for anyone, without signing in.","accept":["\"Create share link\" shows a /splitter/s/<id> link","In a fresh browser: Harbor Grill with Ana $31.93, Ben $23.05, Cleo $26.85 and no edit controls"]},{"id":"FR-SHR-02","weight":"Should","text":"The link of a deleted bill stops working and says so.","accept":["After deleting the shared bill from History the link shows \"This bill no longer exists — its owner deleted it.\""]}]},{"id":"HST","title":"Bill history","summary":"Saved bills of a signed-in user.","reqs":[{"id":"FR-HST-01","weight":"Must","text":"A signed-in user lands on history, which lists saved bills newest first, 20 per page.","accept":["Lands on /splitter/history with 23 bills for Maya","First rows: Harbor Grill 2026-10-16 $81.83, Corner Market $34.46, Noodle Bar 88 $44.02","\"Older ›\" shows three rows: Green Bowl, Night Market, Bill 20 Jun 2026, with \"‹ Newer\" enabled"]},{"id":"FR-HST-02","weight":"Could","text":"History search matches participant names as well as titles.","accept":["\"rafael\" finds exactly Noodle Bar 88 (2026-10-11) and Golden Wok (2026-09-02)"]},{"id":"FR-HST-03","weight":"Could","text":"A saved bill can be renamed (1 to 80 characters) and duplicated.","accept":["81 characters: \"Title must be 1–80 characters\"","Rename to \"Team lunch\"; Duplicate adds a second \"Team lunch\" at $44.02, 24 bills in total"]}]},{"id":"ATH","title":"Accounts","summary":"Sign-up, verification, reset, lockout and deletion.","reqs":[{"id":"FR-ATH-01","weight":"Must","text":"After five wrong passwords the sixth attempt locks sign-in for 15 minutes, even with the correct password.","accept":["Wrong attempts one to five: \"Incorrect email or password\"","Sixth attempt: \"Too many failed attempts. Try again in 15 minutes.\"","The correct password is still refused with the same message"]},{"id":"FR-ATH-02","weight":"Should","text":"Sign-up refuses taken emails and weak passwords, and a new account stays unverified until its mailbox link is used once.","accept":["Existing email: \"That email is already in use.\"","Password under 8 characters or lacking a letter or number: \"Password must be at least 8 characters and include a letter and a number\"","Valid sign-up signs in with an \"unverified\" banner; the Mailbox link removes it; opening it again: \"This link was already used.\""]},{"id":"FR-ATH-03","weight":"Should","text":"A password reset link changes the password once.","accept":["The new password works and the old one fails","The same link again: \"This reset link was already used. Request a new one.\""]},{"id":"FR-ATH-04","weight":"Could","text":"Account deletion needs the exact word DELETE and a password change needs the correct current password.","accept":["\"delete\": \"Type DELETE to confirm\"","Wrong current password: \"Current password is incorrect\""]}]},{"id":"GRP","title":"Groups","summary":"Group list, membership and invites.","reqs":[{"id":"FR-GRP-01","weight":"Must","text":"The group list shows each group's member count, currency and the user's balance direction.","accept":["Flat 4B 🏠 (4 members) \"gets back $40.00\"; Lisbon trip ✈️ (3 members) \"owes €85.08\""]},{"id":"FR-GRP-02","weight":"Must","text":"A member cannot leave a group while their balance is not zero.","accept":["Leaving Flat 4B with −$80.00: \"You can't leave while your balance is −$80.00. Settle up first.\"; the member stays"]},{"id":"FR-GRP-03","weight":"Could","text":"A valid invite code joins the group; a revoked code does not.","accept":["LISBON-JOIN opens Lisbon trip with 4 members","FLAT4B-NOPE: \"This invite link is no longer valid.\""]},{"id":"FR-GRP-04","weight":"Should","text":"Only the group admin can remove members, make admins or share invites; others can still leave.","accept":["A non-admin sees no \"Remove\", \"Make admin\" or invite link controls in Flat 4B; \"Leave group\" is present"]}]},{"id":"EXP","title":"Group expenses","summary":"Expenses with several split modes and currencies.","reqs":[{"id":"FR-EXP-01","weight":"Could","text":"The expense list shows amounts and dates for every split mode, recurring expenses and drafts.","accept":["Flat 4B: Concert tickets $150 (10 Oct), Pizza night $80, Cleaning supplies $45, Groceries $120, Internet $60 (repeats monthly)","Banner: a draft \"Internet\" due 2026-10-15 awaiting confirmation"]},{"id":"FR-EXP-02","weight":"Should","text":"Exact and percent splits show a live remainder and cannot be saved until it is zero.","accept":["Taxi $40.00 exact 10 / 10 / 10: remainder $10.00 left, Save disabled; Sam 10: remainder 0, Save enabled","Percent 25 / 25 / 25 / 20: 5% left, Save disabled"]},{"id":"FR-EXP-03","weight":"Should","text":"An expense paid in another currency keeps its original amount, rate and rate date, and balances are in the group currency.","accept":["Lisbon trip: Museum tickets €49.68, Dinner in Alfama €84.60, Hostel (3 nights) €270.00","Museum tickets: originally $54.00 at 0.92, rate of 2026-09-12","Balances: Theo +€135.24, Maya −€85.08, Jonas −€50.16"]}]},{"id":"BAL","title":"Balances and settle-up","summary":"Who owes whom and the suggested payments.","reqs":[{"id":"FR-BAL-01","weight":"Must","text":"Group balances are computed from the expenses and payments and net to zero.","accept":["Flat 4B: Maya +$40.00, Theo +$70.00, Priya −$80.00, Sam (guest) −$30.00"]},{"id":"FR-BAL-02","weight":"Must","text":"Suggested payments are simplified across the whole group, using the fewest transfers that settle every balance.","accept":["Flat 4B suggests exactly three: Priya → Theo $70.00, Sam → Maya $30.00, Priya → Maya $10.00"]}]},{"id":"PAY","title":"Payments","summary":"Sandbox in-app payments, fees, refunds and requests.","reqs":[{"id":"FR-PAY-01","weight":"Must","text":"A card payment shows the fee quote, cannot exceed the amount owed, and on success reduces the debt.","accept":["Priya paying Maya $10.01 when she owes $10.00: \"You owe Maya Lin $10.00 — you can't pay more than that\"","$10.00 by card: fee \"2.9% + 0.30\" = $0.59, total $10.59","Status turns to succeeded within about 5 s; Flat 4B balances Maya +$30.00, Priya −$70.00"]},{"id":"FR-PAY-02","weight":"Must","text":"The bank transfer fee is 0.8% capped at $5.00.","accept":["A $700 payment by bank shows fee \"0.8%, max 5.00\" = $5.00 and total $705.00"]},{"id":"FR-PAY-03","weight":"Must","text":"A refund reverses the payment in the ledger so balances return to what they were before it.","accept":["The recipient refunds the $10.00 payment: status refund_pending then refunded within about 2 s","Balances return to Maya +$40.00, Priya −$80.00"]},{"id":"FR-PAY-04","weight":"Should","text":"A recipient whose payout account is not verified cannot be paid.","accept":["\"Theo Park can't receive payments yet — their payout account isn't verified\""]},{"id":"FR-PAY-05","weight":"Could","text":"In a country where payments are unsupported, payments are unavailable but everything else, including manual payments, keeps working.","accept":["\"Payments are not available in your country yet. Everything else keeps working.\"","\"Record a payment\" saves and updates balances"]},{"id":"FR-PAY-06","weight":"Could","text":"Payment requests are listed for the payer and can be declined with a reason.","accept":["Theo asks $70.00 \"Concert tickets 🎟️\" (Flat 4B), open; declining with \"Paid in cash\" shows declined with the reason"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"An unsaved bill survives a page reload in the same browser, and \"Start new bill\" asks for confirmation before discarding it.","accept":["After reload: still on People with Ana, Ben, Cleo and five items","Start new bill, confirm: back to Upload with no items"]},{"id":"NFR-02","weight":"Should","text":"Each browser tab works on its own seeded copy of the data, which can be reset."}],"glossary":[{"term":"Settle-up","meaning":"The set of payments that brings every group balance to zero."},{"term":"Sandbox processor","meaning":"A simulated payment network; no real money moves."}],"outOfScope":["Reading arbitrary real photos accurately (samples are deterministic)","Real card or bank networks","The native share sheet on mobile"]},{"ns":"eapp","title":"Annuity e-Application","version":"1.0","summary":"The Annuity e-Application is an agent portal for fixed indexed annuity new business. Agents sign in, see their own cases, and complete an 11-module wizard (Case & Owner, Annuitant, Trust Certification, Beneficiaries, Suitability, Riders, Premium, Replacement, Bank Draft, Allocation, Agent) whose modules and fields appear according to earlier answers. A review page submits, withdraws or duplicates the case, and a printable application shows visible answers with sensitive numbers masked.","problem":"Annuity applications are long, rule-heavy and regulated. Agents need the form to show only what applies, to stop incorrect data (ages, totals, funding order, routing numbers) before submission, and the carrier needs sensitive data protected on every printed or copied record.","personas":[{"name":"Appointed agent","need":"Enter an application quickly, be told exactly what is wrong, and submit it with a confirmation number."},{"name":"Trainee agent","need":"Prepare drafts while the carrier appointment is pending without being able to submit."},{"name":"Carrier new-business reviewer","need":"Receive complete, consistent applications with PII masked in every printout."}],"goals":["Only rule-compliant applications can be submitted.","Questions appear and disappear with the answers that drive them; hidden answers never persist or print.","Agents see only their own cases; PII is masked in print and dropped when a case is duplicated."],"features":[{"id":"AUT","title":"Sign-in and access","summary":"Agent authentication, lockout and case ownership.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"A signed-in agent sees only her own applications, newest first.","accept":["Header shows the agent name \"Dana L Rivera\" and \"NPN 18273645\"; heading \"My applications\"","Rows EA-24015, EA-24014, EA-24013, EA-24012, EA-24011, EA-24010; EA-24016 (another agent) is absent","EA-24010 Withdrawn; EA-24011 and EA-24012 Submitted; the rest Draft"]},{"id":"FR-AUT-02","weight":"Must","text":"Five consecutive wrong passwords lock the account; after that even the right password is refused.","accept":["Each wrong attempt: \"Email or password is incorrect\"","Next attempt with the right password: \"Too many sign-in attempts. Your account is locked — contact your agency administrator.\""]},{"id":"FR-AUT-03","weight":"Could","text":"Submitting an empty sign-in form is refused on the client with \"Enter your email and password\"."},{"id":"FR-AUT-04","weight":"Must","text":"An agent cannot open another agent's application by typing its URL.","accept":["/eapp/case/EA-24016/case shows \"This application belongs to another agent\" with a link back to My applications"]}]},{"id":"LST","title":"Case list","summary":"Tabs, search, attention badges and draft deletion.","reqs":[{"id":"FR-LST-01","weight":"Should","text":"Status tabs filter by status and search matches client name or case id.","accept":["\"Submitted\" tab: EA-24012 and EA-24011 only","Search \"whitf\": only EA-24013; \"24014\": only EA-24014; \"zzz\": \"No applications match.\""]},{"id":"FR-LST-02","weight":"Could","text":"The attention count beside a draft equals the sum of the per-section counts in its wizard.","accept":["EA-24013 shows 41 = Beneficiaries 1 + Suitability 31 + Premium 4 + Allocation 1 + Agent 4; EA-24015 shows no badge"]},{"id":"FR-LST-03","weight":"Should","text":"Drafts can be deleted after a confirmation; submitted applications have no Delete action.","accept":["\"Delete this draft?\" dialog; Cancel keeps the row","\"Delete draft\" shows toast \"Draft EA-24014 deleted\" and removes the row"]}]},{"id":"OWN","title":"Case & Owner","summary":"Product, issue state, plan type and owner details.","reqs":[{"id":"FR-OWN-01","weight":"Must","text":"A new application gets the next case id, today's application date and the signed-in agent as primary agent at 100%.","accept":["New application opens EA-24017 on \"Case & Owner\" with today's date as MM/DD/YYYY","Agent module: Total Number of Agents 1, Commission Option A, Primary agent Dana L Rivera, license TX-2209381, NPN 18273645, Northgate Financial Partners, (512) 555-0142, 100%"]},{"id":"FR-OWN-02","weight":"Must","text":"The owner section follows the owner type.","accept":["Individual: First/Middle/Last Name, Date of Birth, SSN, Gender, U.S. Citizen, plus annuitant and joint-annuitant questions","Corporation: \"Corporation Name\" and \"Tax ID\" replace the person fields; annuitant questions disappear","Trust: \"Owner Name\", \"Tax ID\", \"Trust Date\" and a \"Trust Certification\" module appear"]},{"id":"FR-OWN-03","weight":"Must","text":"Qualified plans cannot have a Joint owner, so Joint is not offered as Owner Type.","accept":["Non-Qualified: options include Joint","Traditional IRA: Individual, Corporation, Trust, Custodian only; help text \"Qualified plans can't have joint owners.\""]},{"id":"FR-OWN-04","weight":"Must","text":"The issue state must be one in which the agent is licensed.","accept":["Nevada for a Texas-licensed agent: \"You aren't licensed in Nevada. Choose a state you are licensed in.\"","Choosing Wyoming clears the error"]},{"id":"FR-OWN-05","weight":"Must","text":"Owner age on the application date must be within the product limits, counting birthdays; Income FIA owners must be 40 to 80.","accept":["Application date 10/16/2026: DOB 10/16/1945 is age 81 and gives \"The owner must be 40–80 on the application date for the Income Fixed Indexed Annuity (age 81)\"","DOB 10/17/1945 is age 80 and is accepted","DOB 10/17/1986 is age 39 and is rejected; 10/16/1986 is age 40 and is accepted"]},{"id":"FR-OWN-06","weight":"Could","text":"Masked inputs format as typed and invalid values show specific messages.","accept":["02/30/1964: \"Enter a valid date (MM/DD/YYYY)\"; 12/01/2030: \"Date of birth can't be in the future\"","SSN 900123456 formats to 900-12-3456, hidden until \"Show\", and gives \"This SSN isn't valid\"","Phone 1165550127: \"Enter a 10-digit phone number\"; email \"marisol@vega\": \"Enter a valid email address\""]},{"id":"FR-OWN-07","weight":"Could","text":"Address lookup suggests addresses after 3 characters and fills street, city, state and ZIP from the chosen suggestion.","accept":["\"ju\": no suggestions","\"Juniper\" then \"1418 Juniper Hollow Dr, Austin, TX 78745\" fills Street 1418 Juniper Hollow Dr, City Austin, State Texas, Postal Code 78745","\"nowhere lane\": \"No matching addresses — enter it below\""]},{"id":"FR-OWN-08","weight":"Could","text":"Joint ownership adds a joint-owner block; \"Same Address as Owner\" hides the joint-owner address; the joint-annuitant answer must agree with the joint owner being an annuitant.","accept":["Owner Type Joint shows \"Is the joint owner the spouse?\" and a \"Joint owner\" section","Tick \"Same Address as Owner\": street, city, state and postal code disappear","Joint owner is an annuitant = Yes with no joint annuitant: \"You said the joint owner is one of the annuitants — answer Yes\""]},{"id":"FR-OWN-09","weight":"Must","text":"Save & continue is blocked while required fields are empty, listing each one; Save draft still saves.","accept":["Stays on Case & Owner with a summary \"N fields need attention\" linking each field","Product, Issue state, Plan Type, Owner Type: \"Make a selection\"","Save draft shows \"All changes saved\""]}]},{"id":"ANT","title":"Annuitant","summary":"Annuitant details when different from the owner.","reqs":[{"id":"FR-ANT-01","weight":"Could","text":"When the annuitant is the owner the module shows only a note; otherwise annuitant details are required.","accept":["Same: \"The owner is the annuitant — no separate annuitant details are needed.\"","Not the same: \"Annuitant details\" with First/Last Name, Date of Birth, SSN, Gender required"]}]},{"id":"TRU","title":"Trust Certification","summary":"Trustees, grantors and trust beneficiaries.","reqs":[{"id":"FR-TRU-01","weight":"Could","text":"Trustee, grantor and trust-beneficiary blocks follow the counts and the trust type.","accept":["EA-24014: Oakridge Family Trust, Revocable, Tax ID 83-6012457; Trustee 1 Walter Oakridge (Trustee), Trustee 2 Lydia Oakridge-Vance (Officer); Grantor Walter Oakridge 10/08/1949","Trust beneficiaries = 2 shows two \"Trust beneficiary\" blocks","Trust Type \"Charitable organization or foundation\" hides grantors and trust beneficiaries"]},{"id":"FR-TRU-02","weight":"Could","text":"The printed trust certification names a signer as \"Trustee\" only when the title is Trustee; any other title signs as Authorized Signer.","accept":["/eapp/case/EA-24014/pdf, TRC-130 (4/26): \"Section 4 — Trustee 1 signs as Trustee\", \"Section 4 — Trustee 2 signs as Authorized Signer\""]}]},{"id":"BEN","title":"Beneficiaries","summary":"Primary and contingent beneficiaries.","reqs":[{"id":"FR-BEN-01","weight":"Must","text":"Primary beneficiary allocations must total exactly 100%.","accept":["EA-24013 (60 and 30): Primary Total 90% and \"Primary allocations must total 100% (now 90%)\" on beneficiary 2","60 + 39: Primary Total 99% and still \"(now 99%)\"","60 + 40: Primary Total 100%, no error"]},{"id":"FR-BEN-02","weight":"Could","text":"A beneficiary that is not a living person shows entity fields and a limited relationship list; \"Self\" is offered only to primary beneficiary 1.","accept":["Living Person = No: Name, DOB, SSN, Gender disappear; Entity Name, Trust Date, Tax ID appear; Relationship offers only \"Other (Non-Natural Entities)\"","Contingent lists never offer \"Self\""]},{"id":"FR-BEN-03","weight":"Should","text":"On an Accumulation FIA with a non-spouse joint owner, primary beneficiary 1 is fixed as Self, 100%, living person, and read-only.","accept":["Number of Primary Beneficiaries 1: Living Person Yes, Allocation % 100, Relationship Self, all disabled with \"(set by the product rules)\""]}]},{"id":"SUI","title":"Suitability","summary":"Financial profile, objectives and needs.","reqs":[{"id":"FR-SUI-01","weight":"Should","text":"Income, disposable income, assets, debt and net worth are computed live from the entries.","accept":["EA-24015: gross monthly income $4,570.00; disposable $970.00; Total assets $457,000.00; Total debt $9,400.00; Net worth $447,600.00","Social Security 3,000 shows 3,000.00; income $5,120.00; disposable $1,520.00"]},{"id":"FR-SUI-02","weight":"Could","text":"Objective ranks 1 to 5 are used once each.","accept":["A duplicate rank gives \"Rank 1 is already used for “…”\" on the later field"]},{"id":"FR-SUI-03","weight":"Could","text":"Insurance need \"None\" cannot be combined with another need.","accept":["\"“None” can't be combined with other needs\""]},{"id":"FR-SUI-04","weight":"Could","text":"Follow-up questions appear only when relevant and are then required.","accept":["Withdrawals Yes: amount, frequency, purpose","Employment Employed: employer name and occupation","Risk tolerance Aggressive: \"Explain why a fixed indexed annuity is appropriate for an aggressive investor\""]}]},{"id":"RDR","title":"Riders","summary":"Optional riders per product.","reqs":[{"id":"FR-RDR-01","weight":"Should","text":"Riders are offered per product and any combination of them can be selected at once.","accept":["Income FIA (EA-24015): Free Withdrawal Enhancement Rider and Enhanced Death Benefit Feature, no Premium Bonus Rider","Ticking Free Withdrawal Enhancement Rider keeps Enhanced Death Benefit Feature ticked","Accumulation (EA-24013) offers three riders including Premium Bonus Rider"]}]},{"id":"PRM","title":"Premium","summary":"Payment amounts, funding sources and replacement questions.","reqs":[{"id":"FR-PRM-01","weight":"Must","text":"The premium must meet the product and plan minimum (Income FIA: $25,000.00) and amounts over $2,000,000.00 need home-office approval.","accept":["24,999.99: \"Premium Total $24,999.99 is below the $25,000.00 minimum for this product and plan\"","25,000 is shown as 25,000.00 with Premium Total $25,000.00 and no error","2,000,000.01: \"Premiums over $2,000,000.00 need home-office approval before you submit\""]},{"id":"FR-PRM-02","weight":"Could","text":"Payment types and methods depend on the plan and the type chosen.","accept":["Qualified plan: Transfer, Direct Rollover, Indirect Rollover, Contribution (no 1035 Exchange)","Transfer: \"Is the existing contract issued by this company?\" and Policy/Contract Number; methods CD Redemption by Carrier, Mutual Fund Redemption by Carrier, Other Non-Insurance Exchange, Brokerage Account","\"Another company\" adds Company Name"]},{"id":"FR-PRM-03","weight":"Should","text":"Funding sources must be ordered exchanges first, then transfers, then new money (contributions and rollovers).","accept":["Source 1 Contribution then source 2 Transfer: \"Exchanges come first, then transfers, then new money (contributions and rollovers)\" on source 2 Payment Type"]},{"id":"FR-PRM-04","weight":"Could","text":"The exchange/transfer count must match the source types.","accept":["Count 1 with a single Contribution source: \"You said 1 of the payments come from an exchange or transfer, but 0 sources are set up that way\""]},{"id":"FR-PRM-05","weight":"Should","text":"Replacement answers must be consistent, and a Yes reveals the Replacement module.","accept":["No existing contracts and replacing Yes: \"You answered that there are no existing contracts — this contract can't replace one\"","Existing Yes, replacing Yes, 1 policy: \"Replacement\" appears after Premium"]}]},{"id":"REP","title":"Replacement","summary":"Replaced policies and state acknowledgements.","reqs":[{"id":"FR-REP-01","weight":"Could","text":"A Wyoming replacement needs both Wyoming acknowledgements answered Yes, and a partial transfer needs an amount above $0.00.","accept":["\"Replaced policy 1\" block and \"Notice and acknowledgements\" with two Wyoming questions","No on the policy list question: \"Wyoming requires this before a replacement can be submitted\"","Amount to transfer Partial with 0: \"Partial amount must be more than $0.00\""]}]},{"id":"ACH","title":"Bank Draft (ACH)","summary":"Bank account for ACH payments.","reqs":[{"id":"FR-ACH-01","weight":"Must","text":"Choosing ACH adds the Bank Draft module; routing numbers are validated with the ABA checksum and looked up, and the account number is 4 to 17 digits.","accept":["123456789: \"This routing number isn't valid\" (checksum fails)","021000021: \"✓ Hudson Valley Trust\" and the bank name is filled","011000028 (valid checksum, unknown bank): \"No bank found for this routing number — enter the bank name\"","Account number 12: \"Account number must be 4–17 digits\""]}]},{"id":"ALC","title":"Allocation","summary":"Distribution of the premium across strategies.","reqs":[{"id":"FR-ALC-01","weight":"Must","text":"Only the strategies available for the product are listed, whole percentages only, and the total must be exactly 100%.","accept":["Income FIA lists Fixed Account; Large-Cap Equity Index (Annual point-to-point and Monthly average); Global Balanced Index; Volatility-Controlled 5% Index (Annual); not Technology Leaders, Aggregate Bond or 2-year VC","Seeded total 100%; Fixed Account 30 gives \"Allocations must total 100% (now 105%)\"","12.5: \"Whole percentages only (0–100)\""]},{"id":"FR-ALC-02","weight":"Could","text":"\"Only show funds with an Allocation Percent\" keeps just the funded strategies and their groups.","accept":["Fixed Account 25, Large-Cap Equity Index Monthly average 25, Volatility-Controlled 5% Index 50; groups Fixed, Index – Cap, Index – Participation only"]},{"id":"FR-ALC-03","weight":"Could","text":"Dollar Cost Averaging is not available on fixed indexed products, so the Allocation page has no election control for it.","accept":["No \"Elect Dollar Cost Averaging\" control, duration or percentage on EA-24015"]}]},{"id":"AGT","title":"Agents and commission","summary":"Primary and additional agents.","reqs":[{"id":"FR-AGT-01","weight":"Must","text":"Multiple agents need distinct NPNs and a commission split totalling 100%.","accept":["Total 2 with additional agent at 40% and a repeated NPN: \"The total commission percentage for all agents must equal 100%. (Currently 140%)\" and \"Each agent needs a different NPN\"","Rivera 60 and a different NPN: no errors"]},{"id":"FR-AGT-02","weight":"Should","text":"Florida cases show a FL License Number, a letter followed by 6 digits.","accept":["EA-24013 Primary agent shows \"FL License Number\" W123456 with help \"A letter followed by 6 digits\"","1234567: \"FL License Number is a letter followed by 6 digits\""]}]},{"id":"WIZ","title":"Saving and conditional data","summary":"Concurrent edits and handling of hidden answers.","reqs":[{"id":"FR-WIZ-01","weight":"Should","text":"Saving over a version changed in another tab or window shows a reload banner and does not overwrite.","accept":["\"This application was changed in another tab or window. Reload to see the latest answers.\" with \"Reload\"","After Reload the other tab's changes are shown"]},{"id":"FR-WIZ-02","weight":"Must","text":"Answers to questions that become hidden are dropped on save and never appear in the printed application.","accept":["Withdrawals Yes 500 / Monthly / \"Travel\", then No and save; switching back to Yes shows the three fields empty","The PDF has no \"Withdrawal amount\" row (and no \"Liquidity\" withdrawal rows) under Suitability"]}]},{"id":"SUB","title":"Review, submit, withdraw and duplicate","summary":"Final review and case lifecycle.","reqs":[{"id":"FR-SUB-01","weight":"Must","text":"A complete application is submitted only after the agent attestation and then receives a confirmation number and becomes read-only.","accept":["Submit without attestation: \"Confirm the agent attestation to submit\"","With attestation: banner \"Submitted <today> · Confirmation FIA-7733422\" and edit links disappear","My applications shows EA-24015 Submitted"]},{"id":"FR-SUB-02","weight":"Must","text":"An incomplete application cannot be submitted and the outstanding fields are listed.","accept":["EA-24013: \"41 fields need attention before you can submit\" with a field list; status stays Draft"]},{"id":"FR-SUB-03","weight":"Must","text":"An agent whose carrier appointment is not active can draft but not submit, enforced by the server.","accept":["Banner \"Your appointment with the carrier isn't active yet. You can save drafts but not submit.\"","Submitting with attestation returns the same appointment message, not a field list"]},{"id":"FR-SUB-04","weight":"Could","text":"A submitted application can be withdrawn with a reason, after which it is read-only.","accept":["Empty reason: \"Enter a reason for the withdrawal\"","Banner \"Withdrawn <today> — reason: Client chose a different product\"; no Withdraw button"]},{"id":"FR-SUB-05","weight":"Must","text":"Duplicating an application copies answers but not SSNs, Tax IDs, bank numbers, authorizations or signatures.","accept":["EA-24012 duplicate: toast \"Copied to new draft EA-24017\", Case & Owner shows Desmond Achebe","Owner SSN empty; routing and account empty; authorization unticked; bank name and holder kept"]},{"id":"FR-SUB-06","weight":"Should","text":"Wizard pages of a submitted application cannot be edited.","accept":["Banner \"This application was submitted … and can't be edited.\"; inputs disabled; no Save buttons"]},{"id":"FR-SUB-07","weight":"Could","text":"The review page of a submitted joint exchange case shows the premium breakdown and its sections.","accept":["EA-24011: Premium Total $75,000.00 (1035 Exchange $60,000.00 from Granite State Life GSL-0048213 + Contribution $15,000.00); Replacement section listed; contingent beneficiary Bayou Literacy Foundation 100%"]}]},{"id":"PDF","title":"Application PDF","summary":"Printable application of visible answers.","reqs":[{"id":"FR-PDF-01","weight":"Must","text":"The printable application masks sensitive numbers and never shows a full SSN or bank number.","accept":["EA-24012 header: \"Case EA-24012 · Desmond Achebe · Application date 10/08/2026 · Confirmation FIA-7733418\"","SSN •••-••-4471; Bank Draft (ACH-04 (3/25)) Routing •••••6780; Account •••••••••6789"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"Each browser tab works on its own seeded copy of the data, which can be restored with a reset."}],"glossary":[{"term":"FIA","meaning":"Fixed indexed annuity. Two products are sold: Income and Accumulation."},{"term":"NPN","meaning":"National Producer Number, the agent's unique licence identifier."},{"term":"1035 exchange","meaning":"Tax-free transfer of funds from an existing contract into a new one."},{"term":"ABA checksum","meaning":"The check-digit rule a 9-digit bank routing number must satisfy."}],"outOfScope":["Real carrier submission, e-signature and appointment systems","Real bank or address services (lookups are synthetic)","State-specific forms beyond the Florida and Wyoming rules"]},{"ns":"sketch","title":"Sketch Board","version":"5.3","summary":"Sketch Board is an infinite-canvas whiteboard with a hand-drawn look. People draw with an icon-only toolbar, keep several boards, and every change is saved automatically to the server so a board is exactly as they left it after a reload.","problem":"Quick diagrams and drawings are only useful if what is drawn is what is stored: shapes must keep their type and geometry, and nothing may silently disappear or revert.","personas":[{"name":"Sketcher","need":"Draw freehand and with shapes, and trust that the drawing is saved."},{"name":"Diagram author","need":"Build flow diagrams whose arrows and labels stay attached to their shapes."},{"name":"Board organiser","need":"Create, find, rename and delete boards without losing the one in use."}],"goals":["Every shape is stored with the type and geometry it was drawn with.","Autosave is honest: \"Saved\" means the server holds the current drawing.","Board management (create, rename, delete, order) is predictable and validated the same way in the UI and the API."],"features":[{"id":"CAN","title":"Canvas and scratch board","summary":"First-load experience of a fresh tab.","reqs":[{"id":"FR-CAN-01","weight":"Must","text":"A fresh tab opens the scratch board \"Untitled sketch\" with an empty canvas and the welcome hints.","accept":["URL becomes /sketch/#board=b-scratch and the tab title is \"Untitled sketch · Sketch Board\"","The status chip reads \"Untitled sketch\", \"Saved\", \"0 shapes\"","Welcome text: \"Sketch Board\" and \"Everything you draw is saved to “Untitled sketch”.\"","The toolbar offers Rectangle, Diamond, Ellipse, Arrow, Line, Draw, Text and Eraser"]},{"id":"FR-CAN-02","weight":"Could","text":"The theme toggle switches between light and dark; in dark mode the canvas and toolbar darken, the status chip text stays readable and the menu item reads \"Light mode\"."},{"id":"FR-CAN-03","weight":"Could","text":"Export image produces a PNG named after the board, with a preview of the drawing in the export dialog."},{"id":"FR-CAN-04","weight":"Could","text":"The canvas background colour can be changed from the main menu; it is saved with the board and shown as the board’s swatch in Your boards.","accept":["Seed board Kitchen layout has a pale-yellow swatch"]}]},{"id":"DRW","title":"Drawing tools","summary":"Shape and freehand tools and their keyboard shortcuts.","reqs":[{"id":"FR-DRW-01","weight":"Must","text":"Each tool creates exactly one element of its own type: Rectangle, Diamond, Ellipse, Arrow, Line, Draw (freedraw) and Text.","accept":["One of each of the seven gives \"7 shapes — 1 rectangle, 1 diamond, 1 ellipse, 1 arrow, 1 line, 1 freedraw, 1 text\" in Board details","An ellipse stays an ellipse after it is released; it is never converted to another type"]},{"id":"FR-DRW-02","weight":"Must","text":"Pencil (Draw) strokes keep their full path: a finished stroke is stored as a freedraw element with every sampled point, never reduced to a straight segment.","accept":["A curved stroke has more than 2 points in Board details"]},{"id":"FR-DRW-03","weight":"Must","text":"A multi-point Line ends with Escape and is \"closed\" only when its last point lands on its first.","accept":["Clicking three corners then the first corner again stores \"4 (closed)\"","Ending with Escape about 30 px short of the first point stores \"3\" without \"(closed)\""]},{"id":"FR-DRW-04","weight":"Must","text":"Shapes are stored where they are drawn: a head ellipse about 200 × 180 contains the eyes within its box, the body lies below the head and the ears above it.","accept":["Board details lists x, y, width and height per element, matching the drawn positions"]},{"id":"FR-DRW-05","weight":"Should","text":"Selected shapes can take a fill colour and a solid fill from the properties panel; a Text element holds the typed text.","accept":["A head ellipse with orange background and solid fill is shown orange","Typing \"Cat\" with the Text tool then Escape stores a text row with Text \"Cat\""]},{"id":"FR-DRW-06","weight":"Should","text":"The Eraser removes the shapes it is dragged across, and the removal is saved.","accept":["Erasing one whisker on the 24-shape Cat reference gives \"23 shapes — 8 ellipse, 14 line, 1 text\" after a reload"]},{"id":"FR-DRW-07","weight":"Could","text":"Letter and number shortcuts pick tools.","accept":["R, D, O, A, L, P, T, E select Rectangle, Diamond, Ellipse, Arrow, Line, Draw, Text, Eraser in that order","1 selects Selection and 4 selects Ellipse"]},{"id":"FR-DRW-08","weight":"Should","text":"Select-all, copy and paste carries every shape to another board and leaves the source board unchanged.","accept":["Pasting the Cat reference onto a new board gives \"24 shapes — 8 ellipse, 15 line, 1 text\"; Cat reference still holds 24"]},{"id":"FR-DRW-09","weight":"Should","text":"Arrows bound to shapes follow them when a shape is moved, and a label moves with its container.","accept":["Dragging the \"Valid?\" diamond 100 px left moves its label and the connected arrows","The Sign-in flow board holds 18 shapes — 4 rectangle, 1 diamond, 5 arrow, 8 text"]}]},{"id":"SAV","title":"Autosave, reload and undo","summary":"Persistence of every edit.","reqs":[{"id":"FR-SAV-01","weight":"Must","text":"Edits are saved automatically about 0.7 s after the last change and nothing is saved while a stroke is still being dragged.","accept":["The chip shows \"Saving…\" and then \"Saved\" within about a second of releasing; the shape count updates by +1 / −1","The status does not change while the mouse button is held down"]},{"id":"FR-SAV-02","weight":"Must","text":"After \"Saved\", a reload restores every shape — including pencil strokes with their curve — and the same shape count.","accept":["An ellipse plus one curvy stroke reloads as \"2 shapes\": one ellipse, one freedraw with many points"]},{"id":"FR-SAV-03","weight":"Should","text":"Undo (Ctrl/Cmd+Z) and redo (Ctrl/Cmd+Shift+Z) are saved like any other edit.","accept":["After undoing the last of two shapes and a reload only the first remains (\"1 shape\")","After undo then redo both shapes are present after a reload"]},{"id":"FR-SAV-04","weight":"Must","text":"Reset the canvas (after confirmation) empties the board, and the empty board is what is saved.","accept":["\"0 shapes\", \"Saved\"; still empty after a reload; Your boards shows the board with 0 shapes"]},{"id":"FR-SAV-05","weight":"Could","text":"Each browser tab works on its own seeded copy of the data: boards edited in one tab are not visible in a new tab.","accept":["A new tab shows Untitled sketch with 0 shapes and the four seed boards"]}]},{"id":"BRD","title":"Boards","summary":"List, search, create, rename and delete boards.","reqs":[{"id":"FR-BRD-01","weight":"Must","text":"Your boards lists the four seed boards with shape counts, ordered by last edit, newest first, with the open board marked \"(open)\".","accept":["Order: Kitchen layout (9 shapes, Oct 17), Sign-in flow (18, Oct 15), Cat reference (24, Oct 14), Untitled sketch (0, Oct 12)"]},{"id":"FR-BRD-02","weight":"Could","text":"Search boards is a case-insensitive match on the name.","accept":["\"CAT\" lists only Cat reference","No match shows \"No boards match.\""]},{"id":"FR-BRD-03","weight":"Should","text":"Only a real change updates a board’s \"edited\" time and moves it to the top; merely opening a board does not.","accept":["Opening Sign-in flow and changing nothing leaves it at \"edited Oct 15\"","Moving a shape on Cat reference puts it first with \"edited just now\""]},{"id":"FR-BRD-04","weight":"Must","text":"New board creates an empty board with its own URL, listed first.","accept":["The dialog pre-selects \"Untitled sketch\"","The first new board gets id b-101, URL #board=b-101, tab title \"<name> · Sketch Board\" and chip \"<name>\", \"0 shapes\""]},{"id":"FR-BRD-05","weight":"Should","text":"Board names are 1–60 characters after trimming; runs of whitespace collapse to one space.","accept":["Blank name: \"Board name is required\" and the dialog stays open","61 characters: \"Use 60 characters or fewer\"; exactly 60 is accepted","\"   My    cat   \" is stored as \"My cat\""]},{"id":"FR-BRD-06","weight":"Must","text":"Renaming (from the status chip or the menu) updates the chip and tab title and is stored on the server.","accept":["Renaming Cat reference to \"Cat sketch v2\" shows that name after a reload and in Your boards"]},{"id":"FR-BRD-07","weight":"Should","text":"Delete asks for confirmation naming the board and its shape count, and can be cancelled.","accept":["\"Delete board?\" — \"“Kitchen layout” and its 9 shapes will be deleted. This can’t be undone.\"","\"Keep board\" returns to the list with the board intact; \"Delete board\" removes it"]},{"id":"FR-BRD-08","weight":"Could","text":"Deleting the board that is open opens the scratch board Untitled sketch (#board=b-scratch)."},{"id":"FR-BRD-09","weight":"Should","text":"The last remaining board cannot be deleted.","accept":["Message \"Keep at least one board\"; the board is still listed"]},{"id":"FR-BRD-10","weight":"Could","text":"A link to a missing board shows \"This board doesn’t exist any more.\" with buttons Open “Untitled sketch” and Your boards."},{"id":"FR-BRD-11","weight":"Should","text":"The seeded Cat reference board holds a complete cat of 24 shapes.","accept":["\"24 shapes — 8 ellipse, 15 line, 1 text\"","Three lines are closed (both ears and the nose); the head ellipse is 220 × 200 at 300, 140"]}]},{"id":"API","title":"Board API","summary":"JSON interface to the same boards.","reqs":[{"id":"FR-API-01","weight":"Should","text":"GET /api/sketch/boards lists boards newest-edit first with per-type counts; GET /api/sketch/boards/<id> returns the elements; GET …/summary returns per-shape geometry; an unknown id returns 404.","accept":["Four boards, first id b-kitchen; Cat reference counts {ellipse 8, line 15, text 1}","b-cat has 24 elements; summary shows shapes 24 and cat-ear-l {type line, points 4, closed true}","404 \"Board b-nope not found\""]},{"id":"FR-API-02","weight":"Should","text":"PUT validates the element list.","accept":["elements not an array: 422 \"elements must be an array\"","Unknown type: 422 \"Unknown element type\"","Missing numeric x or y: 422 \"Every element needs numeric x and y\"","3001 elements: 413 \"A board can hold at most 3000 elements\"; 3000 elements: 200"]},{"id":"FR-API-03","weight":"Could","text":"Board create, rename and delete follow the same rules as the UI.","accept":["POST {\"name\":\"API board\"} returns 201 with id b-101","PATCH with an empty name: 422 \"Board name is required\"","Deleting the last board: 409 \"Keep at least one board\"","POST /api/sketch/reset restores the seed boards"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"Keyboard accessibility: every drawing tool has a single-key shortcut (see FR-DRW-07)."},{"id":"NFR-02","weight":"Must","text":"Reliability: the \"Saved\" indicator is shown only when the stored board equals the canvas; no element type or point is lost on save."}],"glossary":[{"term":"Board","meaning":"A named drawing with its own URL, background and shapes."},{"term":"Scratch board","meaning":"The default board \"Untitled sketch\" that a fresh tab opens."},{"term":"Board details","meaning":"Menu view of what the server has stored for the open board: element types, boxes, point counts and closed lines."}],"outOfScope":["Real-time collaboration and share links","Shape libraries and image upload","Pixel-level comparison of exported images"]},{"ns":"gate","title":"Sentinel Gate","version":"4.3","summary":"Sentinel Gate is a sign-in portal that verifies a password and then a human-verification challenge bound to the account: distorted text, canvas math, a 3x3 image grid, a jigsaw slider, drag-shapes-to-outlines, rotate-upright, or a three-stage chain. Repeated failures lock the browser tab for 30 seconds. After sign-in, a security overview shows a score, the signed-in devices, an access log and recovery codes that sit behind a step-up challenge.","problem":"Account owners need a sign-in that stops scripted guessing without locking out real people, and a clear view of which devices are signed in so they can remove the ones they do not recognise.","personas":[{"name":"Account owner","need":"Sign in with a short, fair challenge and manage trusted devices afterwards."},{"name":"Security-minded user","need":"Keep recovery codes hidden until identity is re-verified and see every sign-in event in a log."},{"name":"Operator","need":"Be sure that wrong answers are always refused, repeated failures lock the tab, and the browser never receives the correct answers."}],"goals":["Only a correct password plus a correct challenge answer signs a user in.","Three consecutive failures lock the tab for 30 seconds, whether the failure was a password or a challenge.","Device actions affect exactly the device the user clicked, and the security score always reflects the device list."],"features":[{"id":"PWD","title":"Password step","summary":"First step of sign-in; a correct password reveals the account’s challenge.","reqs":[{"id":"FR-PWD-01","weight":"Must","text":"The sign-in card requires a username and password; \"Continue\" stays disabled until both are filled, and a valid pair replaces the form with the verification card.","accept":["Opening the portal shows the sign-in card with Username, Password and a disabled \"Continue\"","Valid credentials show the challenge assigned to that account"]},{"id":"FR-PWD-02","weight":"Should","text":"A wrong password is refused with a counter that feeds the lockout.","accept":["Message \"Username or password is incorrect\" with \"1 of 3\" and \"2 of 3\" on the first two failures","The third failure locks the tab (see lockout)"]}]},{"id":"TXT","title":"Text captcha","summary":"A five-character code drawn on a noisy canvas.","reqs":[{"id":"FR-TXT-01","weight":"Must","text":"Typing the drawn code signs the user in.","accept":["Title \"Type the characters you see\", stage chip \"1 / 1\" and a 90 s countdown","Five characters from A–Z and 2–9 (no 0, O, 1, I or L)","Success flashes green and opens the security overview; the access log starts with \"Signed in\" followed by \"Challenge · text passed\""]},{"id":"FR-TXT-02","weight":"Must","text":"The answer is not case-sensitive, as the ⓘ hint promises.","accept":["Tooltip \"Letters are not case-sensitive\"","The code typed in lowercase or UPPERCASE is accepted"]},{"id":"FR-TXT-03","weight":"Must","text":"A wrong answer is refused, a fresh code is drawn and one attempt pip turns red.","accept":["Message \"That was not right — try this one\"; the input is cleared and focused","The first of three pips is filled red","The log records a red \"Challenge · text answered incorrectly\" row"]},{"id":"FR-TXT-04","weight":"Could","text":"The ↻ icon draws a new code at most five times; refreshing is not a failure.","accept":["Caption counts \"4 refreshes left\" down to \"0 refreshes left\" and the icon is disabled with tooltip \"Refresh limit reached — answer this challenge\"","All pips stay hollow"]}]},{"id":"MTH","title":"Math captcha","summary":"A short sum drawn on a canvas.","reqs":[{"id":"FR-MTH-01","weight":"Should","text":"An expression of two numbers with +, − or × is drawn; the correct result (which may be negative) is accepted and a wrong one is refused with a new expression.","accept":["Title \"Solve to continue\"","A wrong answer fills the first pip red and draws a new expression","After sign-in the greeting reads \"Welcome back, <first name>\" with the account chip \"math\""]}]},{"id":"GRD","title":"Image grid","summary":"Select every tile of a 3x3 grid that shows the named object.","reqs":[{"id":"FR-GRD-01","weight":"Must","text":"Selecting exactly the tiles that show the named object, in any row, passes the challenge.","accept":["Banner \"Select all squares with\" and the object name; nine pictogram tiles without text","A clicked tile shows a blue ✓ disc and clicking it again deselects it; \"n selected\" matches the ticks","A correct selection that includes bottom-row tiles signs the user in and logs \"Challenge · grid passed\""]},{"id":"FR-GRD-02","weight":"Must","text":"A selection that misses a target tile or includes a wrong tile is refused and the grid is redrawn.","accept":["Message \"That was not right — try this one\" and one red pip","The new grid may show a different object"]}]},{"id":"SLD","title":"Jigsaw slider","summary":"Drag the piece into the notch of a textured picture.","reqs":[{"id":"FR-SLD-01","weight":"Should","text":"Releasing the handle with the piece on the notch (within a few pixels) passes; the release auto-submits.","accept":["Title \"Slide to complete the puzzle\"; the piece follows the handle and the \"Drag →\" label hides while dragging","The track turns green with ✓ and the user is signed in; log shows \"Challenge · slider passed\""]},{"id":"FR-SLD-02","weight":"Should","text":"Releasing far from the notch fails: the track flashes red, the handle snaps back, a new picture with a different notch is shown and one pip turns red."}]},{"id":"MAT","title":"Drag-to-match","summary":"Drag four coloured shapes onto lookalike dashed outlines.","reqs":[{"id":"FR-MAT-01","weight":"Must","text":"Each shape must be placed on the outline of its own shape; correct placement of all four passes.","accept":["Title \"Drag each shape onto its outline\"; at least two outlines are lookalikes (e.g. pentagon vs hexagon, square vs diamond)","A shape glows over an outline and snaps in on drop; dragging it back to the tray empties the outline and disables \"Verify\" until all four are placed","A correct mapping signs the user in and logs \"Challenge · match passed\""]},{"id":"FR-MAT-02","weight":"Must","text":"Any wrong mapping is refused, even when all four outlines are filled.","accept":["Swapping lookalike shapes, or placing every shape on a wrong outline, shows \"That was not right — try this one\"","One pip turns red and a new set of shapes and outlines is shown"]}]},{"id":"ROT","title":"Rotate upright","summary":"Turn a tilted picture upright with ↺ and ↻.","reqs":[{"id":"FR-ROT-01","weight":"Should","text":"A scene tilted by a multiple of 45° is passed when turned upright; the readout shows the total turn.","accept":["Title \"Rotate the picture upright\"; icon-only ↺ and ↻ buttons either side of a \"0°\" readout","One ↻ click turns the picture 45° clockwise and the readout shows \"+45°\"","Verifying an upright scene signs the user in"]}]},{"id":"CHN","title":"Chained verification","summary":"Accounts with a three-stage challenge: grid, then match, then text.","reqs":[{"id":"FR-CHN-01","weight":"Must","text":"Stages run in order with a visible stage chip and progress dots; a failure in a later stage does not restart earlier stages.","accept":["Chip \"1 / 3\" with the first dot filled; passing a stage advances to \"2 / 3\" and \"3 / 3\"","A wrong match answer shows a red pip, stays on \"2 / 3\" with new shapes, not back at the grid","After the last stage the overview chip reads \"grid → match → text\", and the \"Single-stage verification\" finding is not shown","Log order, newest first: Signed in, text passed (stage 3/3), match passed (stage 2/3), match answered incorrectly, grid passed (stage 1/3)"]}]},{"id":"LCK","title":"Attempts and lockout","summary":"Three failures lock the tab; challenges expire.","reqs":[{"id":"FR-LCK-01","weight":"Must","text":"Three consecutive failures, password or challenge, lock sign-in for 30 seconds.","accept":["The card becomes a lock panel with \"Too many attempts. Try again in 30 s\" and a ring countdown; no input is available","Reloading keeps the lock and the remaining seconds","When the countdown ends the empty sign-in form returns and all three pips are hollow"]},{"id":"FR-LCK-02","weight":"Should","text":"A challenge expires 90 seconds after it is issued and an expired challenge cannot be answered.","accept":["Countdown turns amber below 15 s; at 0 the card shows \"Expired\" over a dimmed canvas with a \"New challenge\" button","\"New challenge\" issues a fresh challenge with the timer back at 90 and no failure recorded"]}]},{"id":"DEV","title":"Security overview and devices","summary":"Score, device list, trust and revoke.","reqs":[{"id":"FR-DEV-01","weight":"Should","text":"The device list shows five devices with OS icon, status dot and a trust shield.","accept":["Order: This browser, Work laptop, Phone, Tablet, Build agent","Dots: This browser blue ring (current); Work laptop green; Phone grey; Tablet grey; Build agent green","Filled shield (trusted) for the first three; outline shield for Tablet and Build agent","Build agent last-seen tooltip shows a timestamp about 9 days ago"]},{"id":"FR-DEV-02","weight":"Must","text":"Revoking signs out exactly the device whose ✕ was used (after an inline \"Yes\" confirmation), raises the score and is logged.","accept":["Tooltip \"Revoke\"; inline confirm \"Revoke Tablet? Yes / No\"","Only that row turns grey with a red \"revoked\" dot and a struck-through name; all others are unchanged","Findings change from \"2 untrusted devices still signed in\" to \"1 untrusted device still signed in\" and the log shows \"Device revoked · Tablet (Chrome 140)\""]},{"id":"FR-DEV-03","weight":"Could","text":"The current browser cannot be revoked: its ✕ is disabled with tooltip \"You cannot revoke the browser you are using — sign out instead\"."},{"id":"FR-DEV-04","weight":"Could","text":"Clicking the shield toggles trust and updates the score.","accept":["Trusting Build agent fills the shield (tooltip \"Trusted — click to untrust\") and raises the score","Clicking again restores the outline shield and the previous score"]},{"id":"FR-DEV-05","weight":"Should","text":"The security score is 100 minus 12 per untrusted live device, minus 10 per active device unseen for over 7 days, minus 6 for single-stage accounts, never below 0.","accept":["A score below 100 shows an amber arc with matching findings"]}]},{"id":"REC","title":"Recovery codes","summary":"Eight recovery codes revealed only after a step-up challenge.","reqs":[{"id":"FR-REC-01","weight":"Must","text":"Recovery codes are blurred until the user passes a fresh drag-to-match challenge, then stay available for 2 minutes and are identical on every reveal.","accept":["Eight blurred chips and a \"Reveal\" button; \"Reveal\" opens \"Verify it’s you\" with a drag-to-match challenge","After success: eight codes in XXXX-XXXX form and a \"hides in 2:00\" timer","\"Hide\" blurs them; \"Reveal\" within 2 minutes shows the same codes without a new challenge","Without a passed step-up the codes are refused with \"Verify it is you first\""]}]},{"id":"SES","title":"Session","summary":"Sign-out and access to the signed-in area.","reqs":[{"id":"FR-SES-01","weight":"Must","text":"The security overview requires a signed-in session; after sign-out it is not reachable, including via Back.","accept":["\"Sign out\" from the avatar menu returns to the sign-in card","Opening the overview directly redirects to the sign-in card and Back does not show it","The overview API without a session answers 401 \"Sign in first\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Challenge answers never leave the server: the challenge sent to the browser contains no plain-text answer or list of correct indices.","accept":["An incorrect answer through the API answers 422 \"That was not right\" with a new challenge id","A replaced or expired challenge id is refused"]},{"id":"NFR-02","weight":"Should","text":"Controls without a visible label (↻, ↺, ✕, shield, drag handle) carry a tooltip describing their action."}],"glossary":[{"term":"Attempt pips","meaning":"Three dots under the challenge title; one turns red per failure."},{"term":"Step-up","meaning":"A fresh challenge required to view recovery codes; valid for 2 minutes."},{"term":"Trusted device","meaning":"A device the owner marked trusted; untrusted live devices lower the score."}],"outOfScope":["Audio captcha (the speaker icon is decorative)","Account recovery and password reset","Real device fingerprinting"]},{"ns":"shield","title":"Harbor Rates · Edge shield","version":"7.2","summary":"Harbor Rates is a container freight rates and booking portal that sits behind Edge shield, an edge bot-management layer. An open zone (overview and edge trace) needs no check; the rates board is a managed zone fronted by a checkbox interstitial with up to 3 attempts; the booking desk is a strict zone with a single attempt. Every verdict gets an Incident ID and is listed in a per-tab edge trace, and blocked visitors can file a false-positive report.","problem":"Shippers need live rates and booking space, while the operator must keep automated scraping and abuse away from them. Visitors must be able to understand why they were challenged or blocked, and a wrongly blocked human must have a way to report it with a reference that support can look up.","personas":[{"name":"Shipper (human visitor)","need":"Pass a quick check once, browse rates, and request space without being challenged again for 15 minutes."},{"name":"Automated runner","need":"Be detected consistently: blocked with a visible Incident ID instead of being let through or left in a loop."},{"name":"Security operator","need":"See every edge decision with its score and the signals behind it, and receive false-positive reports that cite the right block."}],"goals":["Detected automation never reaches protected content; humans are cleared for 15 minutes per zone.","Every verdict (challenge, retry, pass, block) is recorded with an Incident ID, score and signals, and what the page shows matches the trace.","Protected data is never returned without clearance, on pages or on the API."],"features":[{"id":"OVR","title":"Overview","summary":"Open landing page that explains the three protection zones.","reqs":[{"id":"FR-OVR-01","weight":"Must","text":"The overview loads without any check and presents the three zones with a coloured tag and status dot each.","accept":["Hero \"Ocean freight rates, refreshed weekly\" with tiles Lanes 16, Carriers 5 and Edge PoP (a three-letter code)","Rates board: amber MANAGED tag, \"Challenge on entry\"; Booking desk: red STRICT tag, \"Challenge on entry\"; Edge trace: grey OPEN tag, \"Open\"","A green \"Protected\" chip in the top bar with tooltip \"Traffic to this site passes through Edge shield\""]},{"id":"FR-OVR-02","weight":"Should","text":"A protection-levels legend states what each level means.","accept":["OPEN: \"No check.\"","MANAGED: up to 3 attempts, then blocked","STRICT: \"One attempt; any automation signal blocks the visitor.\"","The Edge PoP tile tooltip reads \"Point of presence that served this tab\""]},{"id":"FR-OVR-03","weight":"Should","text":"Zone cards reflect the tab’s current state.","accept":["A blocked zone shows a red dot and \"Blocked\" while the other zone is unaffected","A cleared zone shows a green dot with \"Cleared · m:ss\""]}]},{"id":"CHL","title":"Managed challenge (rates board)","summary":"Checkbox interstitial in front of the rates board with up to 3 attempts.","reqs":[{"id":"FR-CHL-01","weight":"Should","text":"Opening the rates board without clearance shows a full-page interstitial with all its parts.","accept":["Tab title \"Just a moment… · harbor-rates.example\"","Heading with the host name, \"Verify you are human by completing the action below.\", a checkbox widget labelled \"Verify you are human\" with the Edge shield mark and \"Privacy · Terms\" links","Chip \"Attempt 1 of 3\" with tooltip \"Failed attempts block this browser for the rates board\"","Footer with an Incident ID (16 hex characters, a dash and the PoP code), \"Protected by Edge shield\" and a MANAGED tag"]},{"id":"FR-CHL-02","weight":"Must","text":"A failed managed check advances the attempt counter and re-issues the challenge.","accept":["Widget shows a red ✕ \"Failed\" and \"✕ Verification failed. Try again.\"","A new Incident ID is shown, the widget resets after about 2 s and the chip reads \"Attempt 2 of 3\", then \"Attempt 3 of 3\""]},{"id":"FR-CHL-03","weight":"Must","text":"The third failed managed check blocks the visitor for the rates board.","accept":["The widget shows \"Blocked\", then the block page appears with tab title \"Attention Required! · harbor-rates.example\"","The explanation states the browser failed the verification challenge three times in a row","The block is per zone: the booking desk is not affected"]},{"id":"FR-CHL-04","weight":"Could","text":"An unused challenge expires after 60 seconds and can be refreshed without costing an attempt.","accept":["Widget turns orange with label \"Expired · Refresh\"","After \"Refresh\" the widget is back to \"Verify you are human\" and the chip still shows \"Attempt 1 of 3\""]}]},{"id":"STR","title":"Strict zone (booking desk)","summary":"Single-attempt interstitial in front of the booking desk.","reqs":[{"id":"FR-STR-01","weight":"Must","text":"A visitor showing automation signals is blocked on the first attempt and never sees the booking form.","accept":["Interstitial subtitle \"Verifying your connection\", a \"Single attempt\" chip and the line \"This area is under strict protection — one attempt only.\"","After the click the widget shows \"Blocked\" and the block page \"Access to this page is denied\" appears with a STRICT tag","The page explains that a security service protects the site from automated access","No Lane select, stepper or \"Request booking\" button is shown"]},{"id":"FR-STR-02","weight":"Must","text":"A block persists for the tab: reloading shows the same block page and Incident ID without a new challenge.","accept":["No checkbox widget is shown after reload and the Incident ID is unchanged","The overview shows the Booking desk as blocked while the Rates board still shows \"Challenge on entry\""]}]},{"id":"BLK","title":"Block page and false-positive report","summary":"What a blocked visitor sees and how they can appeal.","reqs":[{"id":"FR-BLK-01","weight":"Must","text":"The block page always shows the Incident ID of the block, with a copy control, so a report can be filed.","accept":["Headline \"Access to this page is denied\" / \"You are unable to access harbor-rates.example\"","Footer shows the Incident ID as 16 hex characters, a dash and the PoP code, plus a copy icon (toast \"Copied\")","\"Your IP: Click to reveal\" reveals the address"]},{"id":"FR-BLK-02","weight":"Could","text":"The block page diagram marks the edge as the failing hop.","accept":["You / Browser: green ✓ \"Working\"; Edge shield / Security layer: red ✕ \"Blocked\"; host: green ✓ \"Working\"","Nodes are joined by dashed grey connectors"]},{"id":"FR-BLK-03","weight":"Must","text":"A blocked visitor can file a false-positive report; the resulting ticket cites the Incident ID of the block shown on the page.","accept":["\"Report a false positive\" opens a modal showing the block’s Incident ID under the title","On success: \"Report received\", a ticket \"FP-\" followed by six digits and the same Incident ID","The note \"The site owner reviews reports within two business days.\" is shown","The Details box shows a live counter such as \"22 / 500\""]},{"id":"FR-BLK-04","weight":"Should","text":"The report form validates its fields and reports each problem separately.","accept":["Invalid email: \"Enter a valid email address\"","No reason chosen: \"Choose a reason\"","Reason \"Something else\" with fewer than 10 characters of details: \"Describe the problem (at least 10 characters)\"","Nothing is filed until every rule passes"]},{"id":"FR-BLK-05","weight":"Could","text":"The report modal can be dismissed by ✕, Cancel or a click on the backdrop; nothing is filed and the form is empty when reopened."}]},{"id":"TRC","title":"Edge trace","summary":"Per-tab, open-to-all log of every edge decision with its reasoning.","reqs":[{"id":"FR-TRC-01","weight":"Should","text":"A fresh tab shows an empty trace and clean zone tiles.","accept":["MANAGED \"Rates board\" tile: \"Not cleared\", \"Failed attempts: 0 / 3\"; STRICT \"Booking desk\" tile: \"Not cleared\"","Table row \"No edge decisions yet — open the Rates board or Booking desk.\"","Legend chips \"strong · 60\", \"medium · 25\", \"weak · 10\" and a PoP chip equal to the overview tile"]},{"id":"FR-TRC-02","weight":"Must","text":"Every verdict is recorded with the same Incident ID that the visitor saw, the zone, path, verdict, bot score and signals.","accept":["Serving an interstitial adds a blue CHALLENGE row (score \"—\", \"challenge served\") whose Incident ID equals the interstitial footer","A failed managed check adds an amber RETRY row; a block adds a red BLOCK row; a pass adds a green PASS row","BLOCK and RETRY rows always show the numeric score and at least one signal chip (strong signals have a red dot, tooltip \"strong signal\")","The score bar tooltip reads \"Bot score <n> · <zone> threshold <t>\" with thresholds 50 (managed) and 25 (strict)"]},{"id":"FR-TRC-03","weight":"Should","text":"Zone tiles summarise the managed and strict state.","accept":["Managed tile counts failed attempts, e.g. \"Failed attempts: 1 / 3\"","A blocked zone shows a red dot with \"Blocked (attempts)\" for managed and \"Blocked (signals)\" for strict","Three failed managed checks yield two RETRY rows and one BLOCK row"]},{"id":"FR-TRC-04","weight":"Could","text":"The Incident ID in the trace has a copy icon (tooltip \"Copy\", toast \"Copied\")."}]},{"id":"SCR","title":"Detection and scoring","summary":"How client signals and request headers become a verdict.","reqs":[{"id":"FR-SCR-01","weight":"Must","text":"The score is the sum of the hit signals (strong 60, medium 25, weak 10, capped at 100); a visitor passes only when the score is below the zone threshold (managed 50, strict 25).","accept":["Strong signals: automation flag, headless user agent, automation runtime, invalid proof-of-work","Medium: no pointer movement, instant click, missing Accept-Language; weak: empty plugin list, empty language list, zero-size window","A visitor with the automation flag set is never passed in either zone"]},{"id":"FR-SCR-02","weight":"Must","text":"A successful check issues a clearance for that zone only, valid for 15 minutes, and resets that zone’s failed attempts.","accept":["The rates board shows a green \"Cleared · 14:5x\" chip counting down, tooltip \"Edge clearance for the rates board; you will be checked again when it runs out\"","Clearing the managed zone does not clear the strict zone and vice versa","While clearance is valid the zone opens without a new interstitial"]}]},{"id":"RAT","title":"Rates board","summary":"Filterable, sortable board of 16 container lanes (managed zone).","reqs":[{"id":"FR-RAT-01","weight":"Must","text":"After clearing the managed check the rates board opens and lists all lanes with a clearance countdown and summary tiles.","accept":["Header \"Rates board\", \"Week 42 · published Mon 12 Oct 2026\"; lane count \"16 of 16 lanes\"","KPI tiles \"Cheapest 40HC $2,650 · KRPUS→USLAX\", \"Avg transit 21 days\", \"This week ▲ 6 ▼ 6 = 4\"","The trace gains a green PASS row for MANAGED /rates"]},{"id":"FR-RAT-02","weight":"Should","text":"Filters for origin, equipment, direct-only and search combine, and the lane count updates.","accept":["Origin \"VNSGN · Ho Chi Minh City\" gives 5 lanes; with 40HC 3 lanes (R-1001, R-1002, R-1004); with Direct only 2 lanes (R-1002 is transshipment)","Origin VNSGN with equipment \"All\" and Direct only gives 4 lanes (R-1001, R-1003, R-1004, R-1016)","Search \"kestrel\" within that set gives 1 lane (R-1004)"]},{"id":"FR-RAT-03","weight":"Should","text":"Sortable headers sort ascending then descending with a visible direction marker.","accept":["Default All-in ascending (▲), first row R-1016 $560; clicking All-in gives ▼ with R-1012 $6,450 first","Reliab. ascending starts at R-1010 (66%, red dot); Transit ascending starts at R-1016 \"3 d\"","Other sortable headers show a faint ↕"]},{"id":"FR-RAT-04","weight":"Must","text":"A lane’s all-in price equals base plus all surcharges, and the drawer breakdown sums to it.","accept":["R-1007 drawer: \"Shanghai → Rotterdam\", Base $3,015, BAF $480, CAF $55, THC $210, \"All-in per container $3,760\"","Meta row shows carrier \"Orion Lines\", chip \"40HC\" and \"28 days direct\"","Trend shows low $2,800 and high $3,015"]},{"id":"FR-RAT-05","weight":"Could","text":"Row markers read correctly.","accept":["R-1005: VNHPH tooltip \"Haiphong\", carrier MRD tooltip \"Meridian Box\", Transit \"34 d\" with a \"T/S\" badge (tooltip \"Transshipment via a hub port\")","Reliab. \"68%\" with a red dot; Δ wk green \"▼ 4.2%\"; falling sparkline; surcharge badges BAF, PSS, THC, EBS (tooltip \"EBS · Emergency bunker surcharge · $120\")","All-in \"$5,490\" and Valid \"10-31\" with an amber \"!\" badge"]},{"id":"FR-RAT-06","weight":"Could","text":"The star icon pins a lane without opening the drawer, and the \"★ Pinned\" switch filters to pinned lanes.","accept":["Tooltip \"Pin lane\"; the star fills yellow","Pinned filter shows \"1 of 16 lanes\"; unpinning the only lane shows \"0 of 16 lanes\" and \"No lanes match these filters.\""]}]},{"id":"BKG","title":"Booking desk","summary":"Request container space once cleared by the strict check.","reqs":[{"id":"FR-BKG-01","weight":"Must","text":"A cleared visitor can request a booking; the estimate is quantity times the lane all-in price and the request is listed with its reference.","accept":["Lane \"KRPUS → USLAX · HLX · 40GP · $2,450\" with quantity 1 shows \"$2,450 (1 × $2,450)\"; with 3 containers \"$7,350 (3 × $2,450)\"","The − button is disabled at quantity 1","Confirmation \"Booking requested\", \"Reference HBR-xxxxx · total $7,350\"","\"Your requests\" lists HBR-xxxxx · R-1015 · 3 · 2026-11-20 · — · Lotus Garments Co. with an amber \"Requested\" status"]},{"id":"FR-BKG-02","weight":"Must","text":"The booking desk validates lane, quantity, shipper, ready date, UN number and equipment, and creates nothing on failure.","accept":["No lane: \"Choose a lane\" (lane select outlined red)","Ready date after the lane validity: \"Rate is valid until 2026-10-31\" for R-1016","Quantity must be 1–50: \"Containers must be 1–50\"; shipper required: \"Shipper is required\"","Dangerous goods require \"UN\" followed by four digits: \"UN number must look like UN1263\"","Dangerous goods on reefer equipment (40RF): \"Reefer equipment cannot carry dangerous goods\"","A valid dangerous-goods booking shows an orange UN badge on its row"]},{"id":"FR-BKG-03","weight":"Should","text":"Booking a lane from the rates drawer opens the strict check and preselects the lane after clearing.","accept":["R-1001 preselects \"VNSGN → USLAX · ORN · 40HC · $3,715\"","The rates board stays open without a new interstitial"]}]},{"id":"API","title":"API protection","summary":"Protected API routes honour the same clearance as the pages.","reqs":[{"id":"FR-API-01","weight":"Must","text":"Protected API routes never return data without clearance.","accept":["The rates and booking routes answer 403 with marker \"challenge\", the zone and an Incident ID","A challenge request for a zone returns an id, nonce, difficulty 14 bits, attempt 1 and maxAttempts (3 managed, 1 strict)","A failed strict verification answers 403 \"Access to this page is denied\" with marker \"block\" and an Incident ID; later requests return the same block and Incident ID","The trace lists that BLOCK with its signals (e.g. webdriver and pow_invalid)"]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"The Incident ID format is 16 lowercase hex characters, a dash and the PoP code, and an Incident ID is unique per verdict."},{"id":"NFR-02","weight":"Should","text":"State (clearance, blocks, trace, reports) is isolated per browser tab; a fresh tab starts with no clearance and an empty trace."},{"id":"NFR-03","weight":"Should","text":"Challenge and clearance behaviour is deterministic: repeated visits within clearance never loop back to the interstitial."}],"glossary":[{"term":"Incident ID","meaning":"Reference for one edge decision: 16 hex characters, a dash and the PoP code."},{"term":"PoP","meaning":"Point of presence: the edge location that served the tab, shown as a three-letter code."},{"term":"Clearance","meaning":"A 15-minute permission, per zone, issued after a passed check."},{"term":"Bot score","meaning":"Sum of signal weights (0–100) compared with the zone threshold."}],"outOfScope":["Evading or bypassing the shield","Real network or IP reputation","Payment and carrier fulfilment"]},{"ns":"kanban","title":"Stackline · Sprint board","version":"3.4","summary":"Stackline Board is a single-sprint kanban board for a product team. Cards move across To do, In progress, Review and Done under workflow rules (WIP limits, blocked flags, assignee and subtask gates), can be filtered and grouped, and every move keeps the sprint progress and burndown in step with the board.","problem":"A team board is only trustworthy if the workflow rules cannot be bypassed and the numbers on screen (column counts, points, sprint progress, burndown) always agree with the cards.","personas":[{"name":"Team member","need":"Drag work forward, see what is blocked and keep tickets up to date without breaking the rules."},{"name":"Scrum master","need":"See sprint progress and WIP at a glance and rely on limits being enforced."}],"goals":["Workflow rules are enforced identically in the UI and the API, with specific messages.","A card lands exactly where it is dropped and the move is logged.","Sprint totals, column headers and the burndown always reflect the current cards."],"features":[{"id":"HDR","title":"Sprint header","summary":"Sprint dates, goal, progress and burndown.","reqs":[{"id":"FR-HDR-01","weight":"Must","text":"The header shows the sprint name, date range, days left, goal, a progress line with bar and a burndown chart with ideal and actual lines.","accept":["\"Sprint 24\", chip \"6 days left\" (tooltip \"Sprint ends 25 Oct\"), line \"12 Oct – 25 Oct 2026 · Today Mon 19 Oct\"","Goal \"Saved cards and promo fixes ready for the Checkout v2 beta\"","Progress \"11 of 57 pts done (19%)\"; caption \"Burndown · 46 pts left\"","Burndown has 8 dots; first tooltip \"12 Oct: 57 pts remaining\", last (hollow) \"19 Oct: 46 pts remaining\""]},{"id":"FR-HDR-02","weight":"Must","text":"Sprint progress and the burndown follow the cards in Done immediately: done points are the sum of points of cards in Done, percentage is done / total rounded, and the last burndown point equals total minus done.","accept":["PCL-111 (2 pts) to Done: \"13 of 57 pts done (23%)\", \"Burndown · 44 pts left\", tooltip \"19 Oct: 44 pts remaining\", DONE \"5\" and \"13 pts\"","PCL-109 (3 pts) to Done: \"14 of 57 pts done (25%)\", \"43 pts left\""]},{"id":"FR-HDR-03","weight":"Should","text":"Total sprint points follow card estimates, and filters never change sprint totals.","accept":["Setting PCL-117 to 3 points gives \"11 of 60 pts done (18%)\"","With \"Only my issues\" on, progress stays \"11 of 57 pts done (19%)\""]}]},{"id":"BRD","title":"Board and cards","summary":"Columns, card display, grouping and reset.","reqs":[{"id":"FR-BRD-01","weight":"Must","text":"Each column header shows its issue count, WIP limit (if any) and story points; a column at its limit is shown in amber. The toolbar and footer show the total number of issues.","accept":["TO DO 8 · 24 pts (tooltip \"No WIP limit\"); IN PROGRESS \"4 / 4\" amber · 19 pts (tooltip \"WIP limit 4\"); REVIEW \"2 / 3\" · 3 pts; DONE 4 · 11 pts","Order: TO DO PCL-101 to 105, 116, 117, 118; IN PROGRESS PCL-106 to 109; REVIEW PCL-110, 111","\"18 issues\"; footer \"Stackline board · 18 issues · build 3.4.0\"; TO DO ends with \"+ Create issue\""]},{"id":"FR-BRD-02","weight":"Should","text":"A card shows type glyph, key, title, epic chip, label dots, due chip, subtask meter, priority chevron, points badge and assignee avatar, each with a tooltip.","accept":["PCL-101: tooltips \"Story\", \"Epic: Checkout v2\", \"frontend\", \"mobile\", \"Due 23 Oct\", \"1 of 2 subtasks done\", \"Priority: High\", \"5 story points\", \"Anh Nguyen\""]},{"id":"FR-BRD-03","weight":"Could","text":"Overdue, due-today, unassigned and unestimated cards are visually distinct.","accept":["PCL-102: red chip \"17 Oct\", tooltip \"Overdue · was due 17 Oct\", \"Priority: Urgent\", dashed avatar \"Unassigned\"","PCL-108: yellow chip \"Today\", tooltip \"Due today\"","PCL-117: badge \"–\", tooltip \"Not estimated\", \"Priority: Low\""]},{"id":"FR-BRD-04","weight":"Could","text":"A blocked card is marked with a red bar and flag; a done card has a struck-through key and a green tick.","accept":["PCL-108: tooltip \"Flagged as blocked\", comment count \"2\"","PCL-112: struck-through key, tick tooltip \"Done\""]},{"id":"FR-BRD-05","weight":"Should","text":"Grouping splits the columns into lanes by assignee (people in order, Unassigned last) or by epic (then \"No epic\"); None restores one board.","accept":["Assignee: first lane \"Anh Nguyen · 4 issues\"; last lane \"Unassigned · 2 issues\" with PCL-102 and PCL-116; empty cells read \"No issues\" or \"Nothing done yet\"","Epic: Checkout v2, Onboarding, Reliability, No epic; Reliability holds PCL-107, PCL-108, PCL-113"]},{"id":"FR-BRD-06","weight":"Could","text":"The reset control restores the seeded sprint and clears all filters.","accept":["Tooltip \"Reset sprint data\"; toast \"Sprint data restored\""]}]},{"id":"FLT","title":"Filters","summary":"Mine, assignee, label and search filters.","reqs":[{"id":"FR-FLT-01","weight":"Must","text":"Only my issues keeps the signed-in user’s cards, shows partial counts and can be cleared.","accept":["Signed in as Chi Tran (CT); \"Showing 3 of 18 issues\": PCL-103, PCL-109, PCL-112","Headers \"1 of 8\", \"1 of 4 / 4\", \"0 of 2 / 3\" (REVIEW \"No issues\"), \"1 of 4\"","\"Clear filters\" restores all 18 cards"]},{"id":"FR-FLT-02","weight":"Should","text":"Avatar quick-filters combine as OR and include an Unassigned option.","accept":["AN + BL: \"Showing 7 of 18 issues\"","Unassigned only: PCL-102 and PCL-116, \"Showing 2 of 18 issues\""]},{"id":"FR-FLT-03","weight":"Must","text":"The Labels filter (\"SHOW ISSUES WITH ALL OF\") keeps only issues carrying every ticked label; the button shows the number ticked.","accept":["Labels: frontend, backend, payments, mobile, a11y, tech-debt","frontend + payments: badge \"2\", \"Showing 2 of 18 issues\": PCL-106 and PCL-111","frontend + mobile: PCL-101 and PCL-109"]},{"id":"FR-FLT-04","weight":"Should","text":"Search matches key and summary, case-insensitively.","accept":["\"card\": only PCL-106","\"pcl-11\": PCL-110 to PCL-118 (9 issues)","\"zzz\": \"Showing 0 of 18 issues\""]}]},{"id":"MOV","title":"Moving cards","summary":"Drag and menu moves with workflow rules.","reqs":[{"id":"FR-MOV-01","weight":"Must","text":"A column at its WIP limit refuses any further card and says so; the card stays where it was. While dragging over a full column it shows a red \"WIP limit reached\" hint.","accept":["Move PCL-101 to In progress: toast \"WIP limit reached for In progress (4)\"; header stays \"4 / 4\"","Review limit 3: \"WIP limit reached for Review (3)\""]},{"id":"FR-MOV-02","weight":"Must","text":"A dropped card takes the position it was dropped at, in the same or another column, and the move is logged in Activity and persists after reload.","accept":["PCL-103 dropped on the top half of PCL-110: toast \"PCL-103 moved to Review\"; REVIEW reads PCL-103, PCL-110, PCL-111; headers REVIEW \"3 / 3\" · 4 pts, TO DO 7 · 23 pts","Activity \"2026-10-19 09:30 Chi Tran moved To do → Review\"","Re-rank PCL-118 above PCL-101: no toast; Activity \"Chi Tran re-ranked in To do to position 1\"; still first after reload; TO DO stays 8 · 24 pts"]},{"id":"FR-MOV-03","weight":"Must","text":"A flagged issue cannot move forward until the flag is removed; removing and re-adding the flag is logged.","accept":["Status to Review on PCL-108: \"PCL-108 is flagged as blocked — remove the flag first\"; status unchanged","Unflag: toast \"Flag removed from PCL-108\"; then move succeeds: \"PCL-108 moved to Review\"","Activity: \"moved In progress → Review\" and \"flag removed\""]},{"id":"FR-MOV-04","weight":"Must","text":"Review requires an assignee: an unassigned issue cannot enter Review and an issue in Review cannot be unassigned.","accept":["\"Assign someone before moving to Review\"","Unassigning in Review: error under Assignee \"Issues in Review need an assignee\"; assignee unchanged","Assigning shows toast \"Assigned to Duc Pham\""]},{"id":"FR-MOV-05","weight":"Must","text":"Done requires every subtask closed; subtasks of a Done issue cannot be reopened until the issue is reopened.","accept":["PCL-109 (1/3) to Done: \"2 subtasks still open\"; stays in IN PROGRESS","After ticking all: toast \"PCL-109 moved to Done\"; DONE \"5\" and \"14 pts\"","Unticking a subtask of a Done issue: \"Reopen the issue before reopening its subtasks\""]}]},{"id":"ISS","title":"Issues","summary":"Create, edit and discuss issues.","reqs":[{"id":"FR-ISS-01","weight":"Should","text":"An issue is created inline with a type (Task, Bug…) and a summary of 3–120 characters; it gets the next key and is added at the bottom of To do.","accept":["\"ab\": \"Summary must be 3–120 characters\"","\"Fix footer links\": toast \"Created PCL-119\"; red Bug glyph, dashed avatar, points \"–\"; TO DO \"9\", toolbar \"19 issues\", progress unchanged"]},{"id":"FR-ISS-02","weight":"Should","text":"Title, story points (1, 2, 3, 5, 8, 13 or none), priority and labels save on change, update the card and totals, and each change is listed in Activity, newest first.","accept":["PCL-117: renamed, points – → 3, priority low → high, labels frontend → a11y; TO DO \"27 pts\"","Activity lines such as \"priority low → high\", \"story points — → 3\", \"labels: a11y\""]},{"id":"FR-ISS-03","weight":"Should","text":"A due date must fall inside the sprint.","accept":["30 Oct 2026: \"Due date must be within the sprint (ends 2026-10-25)\"","24 Oct 2026 accepted: chip \"24 Oct\", tooltip \"Due 24 Oct\""]},{"id":"FR-ISS-04","weight":"Should","text":"Subtasks of 2–80 characters can be added and ticked; the card meter follows.","accept":["\"x\": \"Subtask must be 2–80 characters\"; Add is disabled while the input is empty","PCL-106 \"2 of 3 done\" → add \"Empty state\" → \"2 of 4 done\" → tick one → card meter \"3/4\", tooltip \"3 of 4 subtasks done\""]},{"id":"FR-ISS-05","weight":"Could","text":"Comments (up to 1000 characters) are listed newest first; @mentions of unknown people are refused and known ones are highlighted with the person’s name in a tooltip.","accept":["\"Ping @ZZ please\": \"Unknown mention @ZZ\"; text kept","\"@AN contrast now 4.6:1\" with Ctrl+Enter: toast \"Comment added\"; badge and card icon \"1\""]},{"id":"FR-ISS-06","weight":"Could","text":"An issue has a deep link that opens its dialog; an unknown key reports it.","accept":["/kanban/issue/PCL-105 opens \"Welcome email shows wrong first name\"; closing returns to /kanban/ and Back reopens it","/kanban/issue/PCL-999: \"Issue PCL-999 not found\""]}]},{"id":"API","title":"Board API","summary":"REST access to the board and moves.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The move endpoint enforces the rules in order — WIP limit, flag, assignee, subtasks — and honours the target index.","accept":["Board: 18 issues; doing 4/4, review 2/3; totalPoints 57, donePoints 11, burndown ending 46","PCL-101 to doing: 409 \"WIP limit reached for In progress (4)\"","PCL-108 to review: 422 \"PCL-108 is flagged as blocked — remove the flag first\"","PCL-116 to review: 422 \"Assign someone before moving to Review\"","PCL-109 to done: 422 \"2 subtasks still open\"","PCL-103 to review at index 0: 200, order PCL-103, PCL-110, PCL-111"]},{"id":"FR-API-02","weight":"Could","text":"Field validation and unknown keys are reported with specific statuses.","accept":["Points 4: 400 \"Story points must be one of 1, 2, 3, 5, 8, 13\"","GET unknown issue: 404 \"Issue PCL-999 not found\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Data integrity: a refused move or edit leaves the board, counts and sprint totals exactly as they were."},{"id":"NFR-02","weight":"Should","text":"Consistency: the UI and the API show the same rule messages."}],"glossary":[{"term":"WIP limit","meaning":"Maximum number of cards a column may hold."},{"term":"Flagged","meaning":"Marked as blocked; the issue may not move forward."},{"term":"Burndown","meaning":"Points remaining (total minus done) at each sprint day against an ideal line."}],"outOfScope":["Multiple sprints or backlog","Real-time collaboration"]},{"ns":"calendar","title":"Tempo · Team calendar","version":"2.8","summary":"Tempo is a team calendar. It shows a person’s week in Day, Week, Month and Agenda views, lets them create, move and resize events on a 15-minute grid, reply to invitations, book rooms with capacity and double-booking rules, find a time that is free for everyone, and change recurring series for one date or for all dates. The signed-in user is Linh Pham; the primary time zone is GMT+7 and a second UTC column can be shown.","problem":"Teams need a calendar they can trust: events must not hide each other, rooms must never be double-booked, a change to one date of a series must not touch the others, and the guest list and time zones must always be correct.","personas":[{"name":"Organizer","need":"Create and change events, invite people, book a room that fits and is free, and edit one date or a whole series."},{"name":"Invitee","need":"See who is going, reply Yes, No or Maybe, and see declined events clearly without losing them."},{"name":"Remote colleague","need":"Read the same hours in UTC next to local time."}],"goals":["Overlapping events are always readable side by side.","A room can never hold more people than it has seats or be booked twice for the same time; back-to-back bookings are allowed.","A change with scope \"This event\" affects exactly one date; a change with \"All events\" affects every date.","Guest summaries always reflect the latest replies."],"features":[{"id":"VIEW","title":"Views and navigation","summary":"Week grid, overlaps, other views, calendars and keyboard.","reqs":[{"id":"FR-VIEW-01","weight":"Must","text":"The Week view opens on the current week with every marker of the day: today, the now line, off-hours shading, all-day events and the time-zone label.","accept":["Heading \"19 – 25 Oct 2026\"; Monday 19 is marked as today","Hours 07:00 to 21:00; gutter header \"GMT+7\" with tooltip \"Ho Chi Minh City\"","A red now-line on Monday at 10:40 with a \"10:40\" tag in the gutter","Rows before 09:00 and after 18:00 are shaded; events already finished today are faded","17 timed events; all-day row shows \"Team offsite\" (Thu 22) and \"Founders’ Day\" (Fri 23, dashed chip)"]},{"id":"FR-VIEW-02","weight":"Should","text":"Events that overlap in time share the day column in lanes side by side and never cover one another; an event that overlaps nothing uses the full width.","accept":["Monday: \"Design review\" (10:00-11:00) and \"1:1 Linh / Quang\" (10:30-11:00) each take half the column and both titles are readable","Wednesday 15:00-16:15: \"Interview — Backend\" on the left half, \"Code freeze sync\" and \"Interview debrief\" stacked on the right","\"Vendor call\" (Wed 09:30) uses the full width"]},{"id":"FR-VIEW-03","weight":"Could","text":"Icon-only controls and event flags are identified by tooltips and accessible names.","accept":["\"Today\" tooltip \"Today, Monday, 19 October\"; chevrons \"Previous week\" / \"Next week\"; view switch shows keys D, W, M, A","Globe \"Show second time zone (UTC)\"; circular arrow \"Reset demo data\"; avatar \"Signed in as Linh Pham\"","Sidebar \"1 invitation awaiting reply\"; Holidays has a lock \"Read-only calendar\"; room Fuji \"Fuji · 6 seats · Floor 3\"","Repeat flag \"Repeats: Weekly on Monday\" / \"Repeats: Every weekday (Mon–Fri)\"; \"Video call\"; \"Private\"; \"Awaiting your reply\"","Accessible name \"Code freeze sync, 15:15 to 15:45, declined\""]},{"id":"FR-VIEW-04","weight":"Could","text":"The Month view shows at most three chips per day and folds the rest into \"+N more\", which opens that day.","accept":["October 2026 grid starts on Monday 28 Sep","Wed 21: two chips then \"+3 more\"; the tooltip lists hidden titles separated by \" · \"","Clicking \"+3 more\" opens Day view for Wednesday, 21 October with 5 events"]},{"id":"FR-VIEW-05","weight":"Could","text":"The Agenda view lists the next 14 days, only days that have events, with the reply state of each row.","accept":["Heading \"19 Oct – 1 Nov 2026\"; first block \"19\", \"Oct · Mon\", \"Today\" pill","Tue 20: \"Sprint planning\" with an \"Awaiting reply\" pill and room \"Halong\"","\"Code freeze sync\" shows \"Declined\" with the title struck through; \"Team offsite\" shows \"All day\" and \"Going\"","Sun 25 has no block; clicking the date \"20\" opens Day view for Tuesday, 20 October"]},{"id":"FR-VIEW-06","weight":"Could","text":"Keyboard keys switch views and move the range, and the mini month mirrors the visible range.","accept":["d, w, m, a switch view; t returns to today; left and right arrows move by one day or week","Day view of Monday 19 shows 4 timed events","Mini month day 19 is named \"Monday, 19 October, has events\"","Clicking Wed 4 Nov in the mini month jumps to the week of 2 Nov"]},{"id":"FR-VIEW-07","weight":"Could","text":"Calendars can be hidden with the sidebar checkboxes, and Reset restores the seeded week.","accept":["Unchecking Holidays hides \"Founders’ Day\" only; checking it brings it back","Reset shows toast \"Calendar restored\": deleted events return, hidden calendars are checked again, 17 timed events"]}]},{"id":"POP","title":"Event popover, permissions and replies","summary":"Details, organizer-only editing and RSVP.","reqs":[{"id":"FR-POP-01","weight":"Must","text":"Clicking an event opens a popover with date and duration, calendar, room, guests and the reply summary; the organizer sees Edit, Delete and Close and no reply buttons.","accept":["\"Design review\": \"Monday, 19 October · 10:00 – 11:00 (1 h)\", room \"Fuji · 6 seats · Floor 3\"","Guests header \"3 guests · 2 yes, 1 maybe\"; rows Linh Pham (you) with an \"Organizer\" tag, Minh Ho, Thao Vu","Hovering a guest shows \"Thao Vu · Maybe\"; Escape closes the popover"]},{"id":"FR-POP-02","weight":"Should","text":"Only the organizer can change an event; for everyone else the popover shows a lock, the event cannot be dragged, and the server refuses the change.","accept":["\"Roadmap Q4\" shows a lock with \"Only Thao Vu can edit\" and a \"Going?\" row with Yes pressed; dragging it does not move it","Dragging \"Interview — Backend\" leaves it at 15:00 - 16:00; lock \"Only Khoa Ngo can edit\"","Changing \"Sprint planning\" through the API returns 403 \"Only Minh Ho can change Sprint planning\"","Read-only calendar events (Founders’ Day, \"Holidays · read-only calendar\") have no Edit or Delete"]},{"id":"FR-POP-03","weight":"Must","text":"Replying Yes, No or Maybe updates the guest summary immediately, the event styling and the sidebar count of unanswered invitations.","accept":["\"Sprint planning\" starts at \"5 guests · 2 yes, 1 no, 1 maybe, 1 awaiting\"","Yes: toast \"You’re going\", header \"5 guests · 3 yes, 1 no, 1 maybe\", solid outline, no \"?\" flag, and the sidebar line \"1 invitation awaiting reply\" disappears","Linh’s own guest row shows the new reply","An organizer replying to their own event is refused: 422 \"You organize Design review\""]},{"id":"FR-POP-04","weight":"Should","text":"A declined event stays visible, striped with a struck-through title, and changing the reply back removes the styling.","accept":["No: toast \"Invitation declined\", summary \"2 yes, 2 no, 1 maybe\"","Maybe: toast \"Replied maybe\", summary \"2 yes, 1 no, 2 maybe\"; the event is no longer striped"]}]},{"id":"CRE","title":"Creating events","summary":"Drag-create, quick-create and validation.","reqs":[{"id":"FR-CRE-01","weight":"Must","text":"Dragging on an empty part of the grid selects a time range that quick-create turns into an event on the Work, Team or Personal calendar.","accept":["Dragging Wed 11:00 to 12:00 shows \"11:00 – 12:00\" and opens a card \"Wed 21 Oct · 11:00 – 12:00 (1 h)\"","The calendar list offers Work, Team and Personal, not Holidays","Saving without a title shows \"Title is required\"","With the title \"Pairing\" the toast is \"Created Pairing\" and the event is in the Work colour"]},{"id":"FR-CRE-02","weight":"Could","text":"A plain click proposes a one-hour slot starting on the half-hour at or before the click.","accept":["Click at about 16:20 gives 16:00 - 17:00; at about 16:40 gives 16:30 - 17:30","Escape creates nothing; Enter in the title saves with \"Created Notes\""]},{"id":"FR-CRE-03","weight":"Should","text":"The editor and the create service validate each field with its own message and nothing is created on error.","accept":["Empty title: \"Title is required\" (400)","End not after start: \"End must be after start\"","Times not on a quarter hour: 422 \"Times snap to 15 minutes\"","Creating on Holidays: 403 \"Holidays is read-only\"","\"All day\" can only be set on new events; for existing events the checkbox is disabled with the tooltip \"All-day can only be set on new events\""]}]},{"id":"ROOM","title":"Guests, rooms and find-a-time","summary":"Free/busy, capacity and double-booking.","reqs":[{"id":"FR-ROOM-01","weight":"Must","text":"The editor shows free/busy rows for every guest and the chosen room and suggests start times that are free for all, never in the past part of today.","accept":["Panel \"Find a time · 21 Oct, 09:00–18:00\" with a row per guest and the room","Busy block tooltips: \"Thao Vu · 09:30–10:30 · Vendor call\" and \"Booked 15:00–16:00 · Interview — Backend\"","For Linh, Khoa and Thao in Sapa for 60 min the suggestions are 10:30, 11:00, 11:30, 12:00; clicking 12:00 sets 12:00 - 13:00","For Linh and Minh on Monday 19 Oct for 30 min: 11:00, 11:30, 13:00, 13:30 (nothing before 11:00 today)","Duration other than 15, 30, 45, 60, 90, 120 gives 400 \"Duration must be 15, 30, 45, 60, 90 or 120 minutes\""]},{"id":"FR-ROOM-02","weight":"Must","text":"A room cannot hold more invited people, organizer included, than it has seats.","accept":["Sapa (4 seats) with 5 people: \"Room Sapa holds 4 people (5 invited)\" under Room, nothing is created","Removing one guest lets Save succeed and the event shows \"Sapa · 4 seats · Floor 3\""]},{"id":"FR-ROOM-03","weight":"Must","text":"A room cannot be booked for a time that overlaps another booking, but a booking that starts exactly when another ends is allowed.","accept":["Fuji is booked 10:00 - 11:00 by Design review; 10:30 - 11:00 is refused with \"Room Fuji is booked 10:00–11:00 by Design review\" (409 by the service)","11:00 - 11:30 in Fuji is created (\"Created Clash\" / \"Sync\") and sits directly under Design review"]}]},{"id":"REC","title":"Recurring events","summary":"Scope \"This event\" versus \"All events\".","reqs":[{"id":"FR-REC-01","weight":"Must","text":"Changing a recurring event asks \"Change recurring event\" with \"This event\" (default) or \"All events\"; \"This event\" changes only that date and leaves every other date unchanged.","accept":["Moving \"1:1 Linh / Quang\" to 11:30 with \"This event\": toast \"1:1 Linh / Quang moved to Mon 19 Oct, 11:30–12:00\"; the moved item shows \"Weekly on Monday · changed for this date\"","On Monday 26 Oct the event is still 10:30 - 11:00 with \"Repeats: Weekly on Monday\""]},{"id":"FR-REC-02","weight":"Should","text":"Choosing \"All events\" applies the change to every date of the series.","accept":["Renaming to \"1:1 Linh / Quang (v2)\" with All events: toast \"Changes saved\"; Monday 19 and 26 both show the new title","Moving to 11:30 with All events puts Monday 26 at 11:30 - 12:00"]},{"id":"FR-REC-03","weight":"Should","text":"The repeat rule can only be changed for all events.","accept":["Repeat \"Does not repeat\" with \"This event\": \"Repeat can only change for all events\" under Repeat (422 by the service)","With \"All events\" the toast is \"Changes saved\" and the series becomes a single event (no Demo day on Fri 30)"]},{"id":"FR-REC-04","weight":"Should","text":"Deleting a recurring event asks for a scope; deleting a one-off event asks for inline confirmation.","accept":["\"Delete recurring event\" with This event / All events and a \"Delete\" button; This event: toast \"Deleted Demo day on 23 Oct\" and Fri 30 still has it","One-off \"Run club\": inline \"Delete Run club?\" with Cancel / Delete; toast \"Event deleted\"","The service without a scope refuses with 400 \"Choose \\\"This event\\\" or \\\"All events\\\"\"; with scope=this it returns {\"ok\":true,\"deleted\":\"occurrence\",\"date\":\"2026-10-26\"}","Unknown event: 404 \"Event e999 not found\""]}]},{"id":"MOV","title":"Moving and resizing","summary":"Drag move, undo and resize on the 15-minute grid.","reqs":[{"id":"FR-MOV-01","weight":"Could","text":"A one-off event can be dragged to a new time in 15-minute steps and the move can be undone.","accept":["Dragging \"Vendor call\" to Thu 10:00 shows a time tag and the toast \"Vendor call moved to Thu 22 Oct, 10:00–11:00\" with \"Undo\"","Undo: toast \"Move undone\" and the event is back on Wed 09:30 - 10:30"]},{"id":"FR-MOV-02","weight":"Could","text":"Dragging the bottom edge changes the end time in 15-minute steps.","accept":["\"Lunch with Ha\" 12:00 - 13:00 dragged to 13:30 shows \"12:00 – 13:30 · 1 h 30 min\"; toast \"Lunch with Ha moved to Mon 19 Oct, 12:00–13:30\"","Its popover says \"Private — others see only “Busy”\""]}]},{"id":"TZ","title":"Second time zone","summary":"The optional UTC column.","reqs":[{"id":"FR-TZ-01","weight":"Should","text":"The globe button shows a UTC label column that is seven hours behind the primary GMT+7 hours and is remembered after reload.","accept":["Tooltip \"Coordinated Universal Time (-7 h)\"","08:00 shows 01:00 and 14:00 shows 07:00","The column is still shown after a reload and disappears when the button is pressed again"]}]}],"nfr":[],"glossary":[{"term":"Lane","meaning":"One of the side-by-side columns inside a day that overlapping events share."},{"term":"Scope","meaning":"The choice between \"This event\" (one date) and \"All events\" (the whole series) when changing a recurring event."},{"term":"Find a time","meaning":"The free/busy strip in the editor that suggests start times free for all guests and the room."}],"outOfScope":["Time zone changes of the primary zone","Email invitations","Multiple users signed in at once"]},{"ns":"sheet","title":"Gridline · Spreadsheet","version":"1.1","summary":"Gridline is a browser spreadsheet. It opens the \"FY27 Marketing Budget\" workbook with three sheets: Budget (ten line items by quarter with totals, plan, variance, variance %, a Status dropdown and a share column, plus a tax line), Rates (inputs) and a protected Summary built from cross-sheet formulas. The grid is drawn on a canvas, with a name box, formula bar, toolbar, keyboard navigation, drag-to-fill, sort, undo/redo, sheet tabs and a Sum / Average / Count status bar.","problem":"Finance and marketing users need a spreadsheet whose numbers they can trust: formulas must recalculate everywhere they are used, fill and sort must keep rows intact, and validation and protection must hold.","personas":[{"name":"Budget owner","need":"Edit quarterly figures and see totals, variance and the Summary update at once."},{"name":"Reviewer","need":"Read a protected Summary that cannot be changed by accident."}],"goals":["Every dependent cell, on any sheet, updates when an input changes.","Fill copies relative references shifted and absolute ($) references unchanged.","Sort moves whole rows with their formulas, notes and owners.","Money is displayed rounded to the nearest cent."],"features":[{"id":"CAL","title":"Workbook and calculation","summary":"Seeded values, totals and recalculation.","reqs":[{"id":"FR-CAL-01","weight":"Must","text":"The workbook opens on the Budget tab, shows \"Saved\" and calculates its seeded values.","accept":["Header: Line item, Owner, Q1, Q2, Q3, Q4, Total, Plan, Variance, Var %, Status, Share","Row 2 Paid search / Minh Ho: 12,500.00; 13,200.00; 14,100.00; 15,800.00; Total 55,600.00; Plan 54,000.00; Variance 1,600.00; Var % 3.0%; Approved; Share 23.3%","G2 holds =SUM(C2:F2)","Total row 13: Q1 41,683.83, Q2 66,783.83, Q3 50,333.83, Q4 79,483.84, Total 238,285.33, Plan 212,300.00, Variance 25,985.33, Var % 12.2%","Sales tax $17,871.40; Total incl. tax $256,156.73"]},{"id":"FR-CAL-02","weight":"Must","text":"Editing an input recalculates its row, the totals and every dependent cell on other sheets; undo restores the previous values.","accept":["C2 = 22500: G2 65,600.00, I2 11,600.00, J2 21.5%, G13 248,285.33, G16 $266,906.73","Summary: Spend $248,285.33, Total incl. tax $266,906.73, Approved spend $133,685.33; VND 6,779,431,000","Ctrl+Z returns C2 to 12,500.00 and G13 to 238,285.33"]},{"id":"FR-CAL-03","weight":"Should","text":"A rate on the Rates sheet feeds Budget and Summary.","accept":["Rates: Sales tax 7.5%, USD to VND 25,400, Contingency 5.0%","Sales tax 8%: G15 $19,062.83, G16 $257,348.16; Summary B6 $19,062.83, B12 $12,867.41"]},{"id":"FR-CAL-04","weight":"Must","text":"AVERAGE ignores blank cells (they are neither counted nor treated as zero), consistently in formulas and in the status bar.","accept":["C14 =AVERAGE(C2:C11) shows 4,631.54 (41,683.83 over 9 non-blank cells)","Selecting C2:C11: Average 4,631.54, Count 9"]},{"id":"FR-CAL-05","weight":"Must","text":"Currency values are shown rounded to the nearest cent, never truncated.","accept":["Sales tax of 17,871.39975 shows $17,871.40 on Budget and on Summary"]},{"id":"FR-CAL-06","weight":"Should","text":"The status bar summarises the selection with Sum, Average and Count.","accept":["C2:C4: Sum 27,100.00, Average 9,033.33, Count 3","D2:D4: Sum 28,200.00","L2:L11: Sum 100.0%, Count 10"]}]},{"id":"EDT","title":"Editing and formulas","summary":"In-cell editor, point mode and errors.","reqs":[{"id":"FR-EDT-01","weight":"Should","text":"While typing a formula, clicking a cell inserts its reference, outlines it and colours the matching text; Esc cancels.","accept":["\"=\" then click G2 gives \"=G2\"; \"*\" then click H2 gives \"=G2*H2\" with differently coloured outlines","Esc leaves the cell empty and removes the outlines","=G2/H2 shows 1.02962963 in general format and 103.0% after the % button"]},{"id":"FR-EDT-02","weight":"Should","text":"A syntactically invalid formula is rejected and never stored; unknown functions and circular references give specific error values with tooltips.","accept":["=SUM(C2:F2 shows \"There's a problem with this formula: Expected )\" and the editor stays open","=SUMM(C2:F2) shows #NAME? with tooltip \"Unknown function or name\"","=M3+1 in M3 shows #CIRC! with tooltip \"Circular reference\"","Delete clears the cells and the red triangles"]},{"id":"FR-EDT-03","weight":"Could","text":"Keyboard navigation and shortcuts work, and the reset button restores the seeded workbook after confirmation.","accept":["A2 then Tab x3 gives D2; Shift+Down x2 selects D2:D4","Ctrl+B makes bold, Ctrl+Z undoes; F2 opens the editor with the caret after \"13200\"","After reset, G13 is 238,285.33"]}]},{"id":"FIL","title":"Fill","summary":"Drag-to-fill with anchored references.","reqs":[{"id":"FR-FIL-01","weight":"Must","text":"Dragging the fill handle copies the formula down, shifting relative references and keeping $-anchored ones.","accept":["L6 is =G6/$G$13; filling to L11 gives L7 =G7/$G$13","L7 2.1%, L8 3.9%, L9 7.4%, L10 7.2%, L11 1.9%; L2:L11 sums to 100.0%","A dashed outline covers the fill range while dragging"]}]},{"id":"SRT","title":"Sort","summary":"Row sort by the active column.","reqs":[{"id":"FR-SRT-01","weight":"Must","text":"Sorting by a column moves entire rows (all columns, formulas and notes) within the line items; the header and Total row stay put and totals are unchanged.","accept":["Sort Z to A by Total: Paid search, Events, Social ads, Content production, Sponsorships, Video, PR agency, Email platform, SEO tooling, Research panels","Row 3: Events, Quang Do, blank Q1, 18,000.00, 4,500.00, 22,400.00, 44,900.00, 40,500.00, On hold; G3 =SUM(C3:F3); G13 stays 238,285.33","The Plan-cut note moves with its row"]},{"id":"FR-SRT-02","weight":"Must","text":"Sorting by Line item A to Z keeps owners and numbers with their line item.","accept":["Order: Content production, Email platform, Events, Paid search, PR agency, Research panels, SEO tooling, Social ads, Sponsorships, Video","Row 2: Content production, Linh Pham, Total 27,550.00; row 3: Email platform, Thao Vu, 9,333.33"]}]},{"id":"SHT","title":"Sheets, validation and protection","summary":"Tabs, rename, status list and the protected Summary.","reqs":[{"id":"FR-SHT-01","weight":"Must","text":"Cells that refer to other sheets recalculate after an edit.","accept":["After C2 = 22500, Summary B3 reads $248,285.33"]},{"id":"FR-SHT-02","weight":"Should","text":"Renaming a sheet validates the name and rewrites references on other sheets, quoting names that need it.","accept":["\"Q1/Q2\": \"Sheet names are 1–31 characters and cannot contain [ ] : * ? / \\ or '\"","\"rates\": \"A sheet named rates already exists\"","\"Budget FY27\" accepted; Summary B3 ='Budget FY27'!G13 still $238,285.33; B10 =SUMIF('Budget FY27'!K2:K11,\"Approved\",'Budget FY27'!G2:G11) = $123,685.33"]},{"id":"FR-SHT-03","weight":"Should","text":"The Status column accepts only Open, Approved, On hold or Rejected, normalises case, and changes feed the Summary.","accept":["Chevron list shows the four values with the current one ticked","K4 to Approved: Summary B10 $151,235.33","\"Pending\": \"K5 only accepts: Open, Approved, On hold, Rejected\"; \"rejected\" is stored as \"Rejected\""]},{"id":"FR-SHT-04","weight":"Should","text":"The Summary sheet is protected: its formulas are visible but typing, deleting and renaming are refused.","accept":["Orange tab with a lock icon; B9 =COUNTIF(Budget!I2:I11,\">0\") shows 6","Toast \"Summary is protected — its cells are read-only\" for typing, Delete and rename","B8 tooltip \"Rounded to the nearest 1,000 ₫.\""]}]},{"id":"MRK","title":"Markers and toolbar","summary":"Small canvas cues and icon buttons.","reqs":[{"id":"FR-MRK-01","weight":"Could","text":"Notes, error flags, dropdown chevrons and negative variances are visible and explain themselves on hover.","accept":["H5 orange note \"Plan cut by 10% after the September review.\"","C5 empty with note \"No Q1 spend — the spring expo was cancelled.\"","J6 #DIV/0! with a red corner triangle, tooltip \"Divide by zero\"","Chevrons on K2:K11; negative Variance (I3 -1,450.00, I8 -266.67, I10 -800.00, I11 -750.00) in red; Share filled only for rows 2-6"]},{"id":"FR-MRK-02","weight":"Could","text":"Icon-only toolbar buttons and the protected tab name themselves in tooltips.","accept":["\"Undo (Ctrl+Z)\", \"Redo (Ctrl+Y)\", \"Bold (Ctrl+B)\", \"Italic (Ctrl+I)\"","\"Format as currency\", \"Format as percent\", \"Number, 2 decimals\", \"Plain number\", \"Fill colour\" (six swatches)","\"Sort rows A → Z by the active column\", \"Sort rows Z → A by the active column\", \"Reset demo workbook\", \"Protected sheet\""]}]}],"nfr":[],"glossary":[{"term":"Absolute reference","meaning":"A reference with $ (for example $G$13) that does not shift when copied or filled."},{"term":"Point mode","meaning":"Clicking cells while typing a formula to insert their references."}],"outOfScope":["Import and export","Charts","Multi-user collaboration"]},{"ns":"flights","title":"Palisade Air","version":"4.0","summary":"Palisade Air is an airline booking site. Travellers search one-way, return and multi-city trips, compare Basic, Standard and Flex fares, pick seats, add bags and extras, pay by card and receive a booking reference. Members sign in to Manage booking to review their trips, change seats and cancel with fare-based refunds. The clock is fixed at Monday 2 Nov 2026, 12:00.","problem":"Travellers must see one honest price for every passenger type, be stopped from choosing seats they are not allowed to take, and know exactly what a cancellation refunds before they confirm it.","personas":[{"name":"Guest traveller","need":"Find a flight, see the full price and book without creating an account."},{"name":"Family traveller","need":"Book adults, a child and an infant together at the correct rates and with seats that obey safety rules."},{"name":"Member","need":"Review upcoming and cancelled trips and cancel with a clear refund breakdown."}],"goals":["The total shown at payment equals fares plus taxes plus seats plus bags, with nothing omitted.","Safety seating rules (exit rows) are always enforced.","A refund always equals what was paid minus the fee and any non-refundable amounts shown in the cancel dialog."],"features":[{"id":"SRC","title":"Search and results","summary":"Trip search, fare calendar and the flight list.","reqs":[{"id":"FR-SRC-01","weight":"Must","text":"The Book a flight screen shows the Palisade Air brand, a single heading \"Book a flight\", the trip types One-way, Return and Multi-city, \"Popular routes\" and the three fare families as cards.","accept":["Fare families Basic, Standard and Flex are shown under \"Three fares on every flight\""]},{"id":"FR-SRC-02","weight":"Must","text":"A one-way search lists every flight of the day, cheapest first, each with three fares.","accept":["ALQ to BKW on 2026-11-10 shows heading \"Albuquerque to Brookwater\" and \"8 of 8 flights\"","First row SK 474, 05:30 to 07:24, Nonstop, from $67.00; fares Basic $67.00, Standard $112.00, Flex $187.00; \"6 seats left\""]},{"id":"FR-SRC-03","weight":"Should","text":"A seven-day fare calendar centred on the searched date shows each day's lowest fare and marks the cheapest day \"Lowest\". Days before today have no price and cannot be selected.","accept":["Search 10 Nov shows 7 Nov to 13 Nov; 10 Nov $67.00 (Lowest), 8 Nov $79.00, 13 Nov $90.00","Search 2 Nov: 30 Oct to 1 Nov show no price; 2 to 5 Nov are $78.00, $75.00, $66.00, $72.00","Clicking a day reloads the list for that day"]},{"id":"FR-SRC-04","weight":"Should","text":"Origin and destination must differ.","accept":["\"Origin and destination must be different\" beside the To field and no results"]},{"id":"FR-SRC-05","weight":"Must","text":"A return trip cannot return before it departs.","accept":["Depart 2026-11-10 and Return 2026-11-09 shows \"Return date cannot be before the departure date\" and lists no flights"]},{"id":"FR-SRC-06","weight":"Should","text":"The earliest searchable date is today (2 Nov 2026) and the latest is 330 days ahead.","accept":["A search for today returns results","2027-12-10 shows \"Flights can be booked up to 330 days ahead\" beside the date"]},{"id":"FR-SRC-07","weight":"Could","text":"Long routes mix nonstop and one-stop flights and name the stop airport and the total duration.","accept":["ALQ to LMZ on 2026-11-10: CM 252 05:15 to 08:12 Nonstop 5 h 57 min, Basic $271.00","SK 837 15:40 to 20:22 \"1 stop · GRV\", Basic $225.00","CM 147 09:15, \"1 stop · CVX\", 7 h 42 min, Basic $249.00"]},{"id":"FR-SRC-08","weight":"Could","text":"The results screen uses a framed-sheet layout: fare calendar on top, filters on the left, flight list on the right, with one heading; an opened row shows three fare cards with prices and rules."}]},{"id":"PRC","title":"Pricing","summary":"Fares, passenger-type rates, taxes and bags.","reqs":[{"id":"FR-PRC-01","weight":"Must","text":"Fares are priced per passenger type: adult 100%, child 75%, infant 10% of the adult fare. Taxes and fees are 12% of the fare plus $18.50 per paying passenger, plus $12.00 per infant.","accept":["1 adult on SK 474 Basic: fare $67.00, taxes $26.54, total $93.54","2 adults, 1 child, 1 infant: fares $134.00 + $50.25 + $6.70, taxes $89.61, total $280.56","1 adult + 1 child: fares $67.00 + $50.25, taxes $51.07, total $168.32"]},{"id":"FR-PRC-02","weight":"Must","text":"Extra checked bags cost $40.00 each beyond the fare allowance (Basic 0, Standard 1, Flex 2), up to 4 bags per passenger, and the order total includes the fee.","accept":["3 bags on Basic: line \"Extra checked bags $120.00\"; total $213.54 (fare $67.00 + taxes $26.54 + bags $120.00)","The add button stops at 4; removing bags reduces the line and the total ($80.00 at 2 bags; line gone and $93.54 at 0)"]},{"id":"FR-PRC-03","weight":"Should","text":"Passenger rules: an infant needs an adult (infants cannot outnumber adults) and an infant does not get a seat.","accept":["\"Infants travel on an adult's lap\" is shown in place of a seat for the infant"]}]},{"id":"PAX","title":"Passenger details","summary":"Names, dates of birth and contact details.","reqs":[{"id":"FR-PAX-01","weight":"Should","text":"Each passenger field refuses bad input with its own message and the screen only advances when all fields are valid.","accept":["\"First name is required\"","\"First name can only contain letters, spaces, hyphens and apostrophes\"","\"Date of birth is required\"","\"Adults must be 16 or older on the travel date\""]}]},{"id":"SET","title":"Seat selection","summary":"Priced seat map with safety rules.","reqs":[{"id":"FR-SET-01","weight":"Should","text":"Legroom seats cost $35.00, exit-row seats $28.00 and standard seats $12.00 on Basic; occupied seats cannot be selected; selecting a new seat releases the previous one and Clear seat removes the charge.","accept":["2A (legroom) $35.00; 14A (exit row) $28.00 and 2A is released","14B and 9A are occupied and not selectable","After Clear seat the total is $93.54"]},{"id":"FR-SET-02","weight":"Must","text":"Passengers under 16 cannot take an exit-row seat.","accept":["\"Passengers under 16 cannot sit in an exit row\" and the seat is not assigned","Row 12 is allowed for the child; an adult without an infant may take 14A"]},{"id":"FR-SET-03","weight":"Must","text":"An adult travelling with an infant cannot take an exit-row seat.","accept":["\"Passengers travelling with an infant cannot sit in an exit row\" and 14A is not assigned"]},{"id":"FR-SET-04","weight":"Could","text":"The seat map shows a 3-3 grid with an aisle, row numbers, a legend and distinct looks for free, occupied, exit-row and legroom seats."}]},{"id":"PAY","title":"Payment","summary":"Card payment with priced summary.","reqs":[{"id":"FR-PAY-01","weight":"Should","text":"Card number, expiry and security code are validated before payment.","accept":["Failing check digit: \"Enter a valid 16-digit card number\"","Expiry 10/26: \"This card has expired\"","Expiry 13/29: \"Use the format MM/YY\"","Two-digit code: \"Security code must be 3 digits\""]},{"id":"FR-PAY-02","weight":"Should","text":"A declined card shows an error, keeps the traveller on Payment and creates no booking.","accept":["Card number 4000 0000 0000 0002 shows \"Your card was declined. Try another card.\" on the card number","Retrying with a valid card confirms the booking"]},{"id":"FR-PAY-03","weight":"Could","text":"The payment form sits beside a sticky price summary (Fares, Taxes and fees, Total) with all text at least 12px."}]},{"id":"BKG","title":"Booking and confirmation","summary":"End-to-end booking and the confirmation receipt.","reqs":[{"id":"FR-BKG-01","weight":"Must","text":"A guest can book a one-way flight with a seat and receives a six-character booking reference and a receipt whose total equals the amount at payment.","accept":["SK 474 Basic with seat 14A: Payment total $121.54 (fare $67.00, taxes $26.54, seat $28.00)","\"Booking confirmed\"; Total paid $121.54; \"Paid with card ending 4242\"; passenger and seat 14A listed"]},{"id":"FR-BKG-02","weight":"Must","text":"A return booking prices both flights, each with its own fare family, in one total.","accept":["SK 474 Standard out + SK 606 Basic back with seat 14A: Fares $206.00, taxes $61.72, seat $28.00, total $295.72","Both flights are listed on the confirmation; seats are chosen per flight on tabs"]},{"id":"FR-BKG-03","weight":"Must","text":"A family booking prices every passenger at their own rate and the confirmation total matches the payment total.","accept":["2 adults, 1 child, 1 infant on SK 474 Basic: total paid $280.56"]},{"id":"FR-BKG-04","weight":"Could","text":"The confirmation shows the booking reference in a bordered box, then the itinerary card, then the passengers card with price lines and a right-aligned \"Total paid\" row."}]},{"id":"MNG","title":"Manage booking","summary":"Member sign-in and booking list.","reqs":[{"id":"FR-MNG-01","weight":"Must","text":"A member signs in and sees their own bookings split into Upcoming and Cancelled.","accept":["Heading \"Manage booking\", \"Signed in as\" the member name","Upcoming: PX4K7M, QT9W2C, ZL6R3N; Cancelled: MB2D8H"]},{"id":"FR-MNG-02","weight":"Must","text":"Bad credentials are refused with specific messages.","accept":["Empty: \"Enter your username or email and your password\"","Wrong password: \"Invalid credentials\" and the screen stays on sign-in"]},{"id":"FR-MNG-03","weight":"Should","text":"A booking detail shows its flights, fares, seats per passenger and payment.","accept":["PX4K7M: Confirmed; ALQ to BKW 12 Nov CM 953 09:20 Standard and BKW to ALQ 16 Nov PA 459 11:30 Standard","Amelia Hart 9C out / 9D back; Noah Hart 9B out / 9E back; Total $660.88, card ending 4242"]},{"id":"FR-MNG-04","weight":"Should","text":"A member can only open their own bookings.","accept":["Looking up a reference of another member shows \"Booking RC5V1T was not found\" and reveals nothing"]},{"id":"FR-MNG-05","weight":"Could","text":"Manage pages require sign-in; a signed-out visit to a booking shows \"Sign in to manage your booking\" and returns to that booking after login."}]},{"id":"CAN","title":"Cancellation and seat changes","summary":"Fare-based refunds and the seat-change window.","reqs":[{"id":"FR-CAN-01","weight":"Must","text":"Cancelling a Standard booking deducts a $75.00 fee per paying passenger per flight from the fare; taxes and seats are refunded. Refund = amount paid minus fee. The dialog shows Paid, Cancellation fee and Refund to card, and nothing changes until confirmed.","accept":["PX4K7M: Paid $660.88; Cancellation fee $300.00; Refund to card $360.88","Escape or \"Keep booking\" closes the dialog; status stays Confirmed","Confirming: status Cancelled and note \"Refunded $360.88 to the card ending 4242, after a $300.00 cancellation fee\"","The booking moves to the Cancelled tab (2 cancelled, 2 upcoming)"]},{"id":"FR-CAN-02","weight":"Should","text":"Cancelling a Basic booking refunds taxes only; the fare and seats are kept. Flex refunds in full.","accept":["QT9W2C: Paid $132.42; Cancellation fee $0.00; Not refundable $103.00; Refund to card $29.42"]},{"id":"FR-CAN-03","weight":"Could","text":"Seat changes close 24 hours before departure.","accept":["QT9W2C (departs next day 05:05): \"Seat changes close 24 hours before departure\" and the seat is unchanged"]},{"id":"FR-CAN-04","weight":"Could","text":"The bookings table has Reference, Trip, Departs, Passengers, Status and Total columns with right-aligned totals; the cancel dialog is centred over a dimmed page with \"Keep booking\" as the quiet button and \"Cancel booking\" as the danger button."}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"At 390px width the sidebar collapses behind a Menu button (with a drawer that \"Close menu\" dismisses), results stack with filters above the list and there is no horizontal page scroll."}],"glossary":[{"term":"Fare family","meaning":"Basic (no bags, no changes, taxes-only refund), Standard (1 bag, $75 fee), Flex (2 bags, free changes, full refund)."},{"term":"Exit row","meaning":"Row 14; not available to passengers under 16 or adults travelling with an infant."}],"outOfScope":["Real payment networks","Loyalty points","Check-in and boarding passes"]},{"ns":"clinic","title":"Larkspur Health","version":"1.1","summary":"Larkspur Health is a patient portal for a regional health system. Patients sign in to see a Home summary, book visits through a five-step wizard, reschedule or cancel them (with a 24-hour online lock), request medication refills, read lab results with high/low flags, exchange secure messages with attachments and keep their profile current. The portal clock is fixed at Monday 19 Oct 2026, 10:30 Pacific Time.","problem":"Patients want to manage routine care without phoning the clinic, and must be able to trust that what the portal shows (flags, refills, unread counts, open slots) is accurate and safe to act on.","personas":[{"name":"Established patient","need":"Book, move and cancel visits, request refills and read results at any hour."},{"name":"New patient","need":"Understand an almost-empty record through clear empty states."},{"name":"Care-team staff","need":"Receive well-formed messages and requests, and avoid double-booked slots."}],"goals":["A time slot can be booked once; a visit inside 24 hours is never changed online.","Lab values are flagged H when above the reference range and L when below.","A refill is only possible when refills remain, and counts always reflect requests made.","Unread counts agree everywhere they are shown."],"features":[{"id":"AUT","title":"Sign-in","summary":"Username and password sign-in for two seeded patients.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Valid credentials open Home with a time-of-day greeting; the navigation lists Home, Appointments, Medications, Lab results, Messages (with an unread badge) and Profile.","accept":["Greeting \"Good morning, Maya\"","Messages badge shows 3 for the seeded record"]},{"id":"FR-AUT-02","weight":"Must","text":"Wrong credentials are refused and protected pages redirect to Sign in.","accept":["Alert \"The username or password is incorrect.\" and the Sign in screen stays open","Opening /clinic/appointments signed out returns to Sign in"]}]},{"id":"HOM","title":"Home","summary":"Tiles, next visit, alerts and medications needing attention.","reqs":[{"id":"FR-HOM-01","weight":"Should","text":"Home summarises the record in four tiles, a Next visit card and Medications needing attention.","accept":["Tiles: Upcoming visits 3, Unread messages 3, Active medications 7, \"Results to review since Oct 14\" 4","Next visit: Tue, Oct 20 at 9:00 AM with Dr. Priya Raman, Primary Care, Annual checkup, Larkspur Central","Needing attention: Lisinopril 10 mg \"1 refill left\" and Metformin 500 mg \"0 refills left\""]},{"id":"FR-HOM-02","weight":"Could","text":"Each alert has an Open link and a dismiss button; dismissing removes only that alert and persists for the session.","accept":["Three alerts at start; two after dismissing the first, also after a reload"]},{"id":"FR-HOM-03","weight":"Should","text":"A patient with almost no history sees helpful empty states.","accept":["Home: \"Good morning, Sam\" and \"No upcoming visits.\"","Appointments: \"You have no upcoming visits.\" with a Book a visit button; Past shows one visit","Medications: \"No medications on file.\"; Messages: \"No messages yet. Write to your care team any time.\" with New message; Lab results: \"No lab results yet.\""]}]},{"id":"APT","title":"Appointments","summary":"Upcoming and past visits.","reqs":[{"id":"FR-APT-01","weight":"Should","text":"Appointments shows Upcoming and Past tabs with counts; each visit shows date, provider and status.","accept":["Tabs \"Upcoming (3)\" and \"Past (4)\"","APT-20481: Tue, Oct 20, 2026 at 9:00 AM, Dr. Priya Raman, Confirmed","APT-20482 and APT-20483 have enabled Reschedule and Cancel buttons"]},{"id":"FR-APT-02","weight":"Could","text":"Past visits list their status without action buttons; a cancelled visit shows its reason.","accept":["APT-19990 Cancelled \"Reason: Schedule conflict\"; APT-19907, APT-19211 and APT-18650 Completed"]}]},{"id":"BKG","title":"Book a visit","summary":"Five steps: specialty, provider, time, details, review.","reqs":[{"id":"FR-BKG-01","weight":"Must","text":"A patient can book a visit end to end and it then appears under Upcoming.","accept":["Step 1 offers seven specialties; Primary Care lists Dr. Priya Raman, Hannah Voss and Dr. Marcus Ellery","Slots are 15 minutes; the review shows provider, time with time zone, visit type, reason and notes","\"You are booked.\" with a confirmation number starting \"APT-\"; Upcoming count increases to 4"]},{"id":"FR-BKG-02","weight":"Should","text":"Providers can be sorted by \"Top rated\" (rating, highest first) or \"Soonest\" (next opening, earliest first).","accept":["Cardiology Top rated: Dr. Tomas Beaulieu (4.8) then Dr. Aiyana Whitecloud (4.5)","Cardiology Soonest: Dr. Aiyana Whitecloud (Mon, Oct 19, 11:30 AM) then Dr. Tomas Beaulieu (Wed, Oct 21, 9:45 AM)","Dermatology Soonest: Dr. Rafael Duarte (Mon, Oct 19, 11:30 AM) before Dr. Lena Koistinen (Tue, Oct 20, 9:00 AM)"]},{"id":"FR-BKG-03","weight":"Could","text":"The Language filter narrows the provider list.","accept":["Primary Care + Spanish: only Dr. Marcus Ellery","Primary Care + German: only Hannah Voss with the badge \"Existing patients only\""]},{"id":"FR-BKG-04","weight":"Should","text":"The visit type follows what the provider offers: in-person-only providers cannot be booked for video.","accept":["Dr. Kwame Asante (Orthopedics): \"In person\" selected and \"Video\" disabled"]},{"id":"FR-BKG-05","weight":"Should","text":"The slot picker covers 14 days, skips Saturdays and Sundays, hides slots already booked and never offers a time less than 60 minutes from now.","accept":["Day strip starts Mon, Oct 19; Oct 24 and 25 show no open times","Mon Oct 19: earliest slot 11:30 AM","Dr. Priya Raman: 13 open slots on Oct 20 (9:00 AM already booked) and 14 on Oct 21"]},{"id":"FR-BKG-06","weight":"Should","text":"Reason-dependent fields are required: \"New symptom\" needs a duration and \"Other\" needs notes.","accept":["\"Tell us how long you have had this symptom\"; with \"Less than 1 week\" the review lists \"Symptom duration: Less than 1 week\"","\"Describe the reason for your visit\" under Notes for Other"]},{"id":"FR-BKG-07","weight":"Must","text":"Cardiology bookings require the chest pain question; answering Yes shows an emergency warning.","accept":["\"Are you having chest pain right now?\" with No and Yes; continuing unanswered shows \"Answer the chest pain question\"","Yes shows \"If this is an emergency, call 911 now. Do not wait for an appointment.\"","No leads to a review listing \"Chest pain: No\""]},{"id":"FR-BKG-08","weight":"Could","text":"Notes accept up to 300 characters with a live counter.","accept":["Counter \"0 / 300\", \"300 / 300\"; 301 characters shows \"Notes can be at most 300 characters\""]},{"id":"FR-BKG-09","weight":"Must","text":"A provider and time can be booked only once: after a booking the slot is shown as taken and a second booking for it is refused.","accept":["The booked time is no longer offered for that provider","A forced second attempt shows \"That time was just taken. Choose another slot.\" and Upcoming never lists two visits at the same time"]}]},{"id":"RSC","title":"Reschedule and cancel","summary":"Change or cancel upcoming visits, subject to a 24-hour lock.","reqs":[{"id":"FR-RSC-01","weight":"Must","text":"A visit more than 24 hours away can be rescheduled to another open slot with the same provider.","accept":["Dialog \"Reschedule appointment\"; picking Wed, Oct 21 at 10:30 AM shows \"New time: Wed, Oct 21 at 10:30 AM\"","After \"Confirm new time\" APT-20482 reads Wed, Oct 21, 2026 at 10:30 AM with Dr. Tomas Beaulieu; Upcoming stays 3"]},{"id":"FR-RSC-02","weight":"Must","text":"A visit more than 24 hours away can be cancelled with a reason; it then moves to Past as Cancelled with that reason.","accept":["Dialog \"Cancel this appointment?\" with a Reason list","Cancelling APT-20483 with \"Feeling better\": Upcoming (2), Past (5), \"Reason: Feeling better\"; Home tile shows 2"]},{"id":"FR-RSC-03","weight":"Must","text":"A visit that starts within 24 hours cannot be rescheduled or cancelled online.","accept":["APT-20481 (Tue Oct 20, 9:00 AM, 22.5 hours away): Reschedule and Cancel disabled with \"Visits within 24 hours can't be changed online. Call (503) 555-0100.\""]}]},{"id":"MED","title":"Medications and refills","summary":"Medication list with refill rules.","reqs":[{"id":"FR-MED-01","weight":"Should","text":"Medications lists active items with refills left and, when a refill is unavailable, the reason; discontinued items are hidden behind \"Show discontinued\".","accept":["Seven active: Atorvastatin 3, Lisinopril 1, Metformin 0, Levothyroxine 5, Albuterol inhaler 2, Vitamin D3 0, Sertraline 2 refills","Vitamin D3: badge \"Over the counter\", \"Over-the-counter item — no refill needed\"","Sertraline: status \"Refill requested\", \"Refill already requested\"","Amoxicillin 500 mg appears with the badge \"Discontinued\""]},{"id":"FR-MED-02","weight":"Must","text":"Requesting a refill with refills remaining decrements the count and marks the medication as requested.","accept":["Dialog \"Request a refill\" shows \"3 refills left\" and a pharmacy list","After \"Send request\" Atorvastatin shows \"Refill requested\", \"2 refills left\" and Request refill disabled"]},{"id":"FR-MED-03","weight":"Could","text":"The refill count uses the singular label for one (\"1 refill left\") and reaches \"0 refills left\" after the last refill is requested."},{"id":"FR-MED-04","weight":"Must","text":"A medication with zero refills left, an over-the-counter item or an already-requested refill cannot be requested.","accept":["Metformin 500 mg: Request refill disabled with \"No refills left — message your provider to renew\"","The count never goes below 0"]}]},{"id":"LAB","title":"Lab results","summary":"Reports with reference ranges and H/L flags.","reqs":[{"id":"FR-LAB-01","weight":"Must","text":"Lab results list every panel with values, ranges and a total of out-of-range results.","accept":["\"8 of 18 results out of range\"; Lipid panel and Basic metabolic panel marked \"New\"","Lipid: Total cholesterol 214 (< 200), LDL cholesterol 138 (< 100), HDL cholesterol 52 (≥ 40), Triglycerides 118 (< 150)","Basic metabolic: Glucose, fasting 104 (70–99), Potassium 3.4 (3.5–5.1); Sodium, Creatinine, BUN and Calcium normal"]},{"id":"FR-LAB-02","weight":"Must","text":"A value above its reference range is flagged H and a value below is flagged L; values in range have no flag.","accept":["Total cholesterol 214 H, Glucose 104 H, TSH 4.9 (0.4–4.0) H, Hemoglobin A1c 6.9 (4.0–5.6) H","Potassium 3.4 L, Hemoglobin 11.8 (12.0–15.5) L, Vitamin D, 25-hydroxy 28 (30–100) L"]},{"id":"FR-LAB-03","weight":"Should","text":"A panel selector and an \"Out of range only\" checkbox narrow the reports without changing the summary.","accept":["\"Out of range only\" keeps only flagged rows; summary still \"8 of 18 results out of range\"","Panel \"Thyroid panel\" shows only TSH and Free T4; \"All panels\" restores all"]}]},{"id":"MSG","title":"Secure messages","summary":"Threads with the care team and attachments.","reqs":[{"id":"FR-MSG-01","weight":"Must","text":"Opening an unread thread marks it read and lowers every unread count (heading, navigation badge and Home tile) by one.","accept":["Six threads, \"3 unread\", badge 3","After opening \"Lipid results and next steps\": \"2 unread\", badge 2, Home tile 2"]},{"id":"FR-MSG-02","weight":"Should","text":"A new message to the care team with a small attachment is delivered and listed first.","accept":["Dialog \"New message\" with To, Subject, Message (counter out of 1000) and an attachment picker","Toast \"Message sent\"; the thread opens with chip \"referral.pdf · 200 KB\""]},{"id":"FR-MSG-03","weight":"Should","text":"Messages are validated: recipient, subject up to 80 characters, attachments PDF/JPG/PNG up to 5 MB.","accept":["\"Choose who to message\"","\"Enter a subject\"","\"Subject can be at most 80 characters\"","\"Attach a PDF, JPG or PNG file\"","\"Attachments can be at most 5 MB\""]}]},{"id":"PRF","title":"Profile","summary":"Contact details, emergency contact and pharmacy.","reqs":[{"id":"FR-PRF-01","weight":"Should","text":"Profile fields reject malformed values with specific messages.","accept":["Phone: \"Enter a 10-digit phone number, for example (503) 555-0147\"","Email: \"Enter a valid email address\"","ZIP: \"ZIP code must be 5 digits\"","Emergency contact: \"Enter a contact name\""]},{"id":"FR-PRF-02","weight":"Should","text":"A valid change is saved, the phone is reformatted and the values persist after a reload.","accept":["\"All changes saved.\"","Entering 5035550199 shows \"(503) 555-0199\" after reload; the chosen pharmacy is kept"]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"Home is usable at 390 px and 768 px with no horizontal scroll: 16 px gutters and a bottom tab bar on phone and tablet, a left sidebar with six links at 1280 px.","accept":["At 390 px the four tiles wrap two per row"]},{"id":"NFR-02","weight":"Could","text":"On a phone, lab results become stacked rows that keep value and flag visible; at desktop width they are a table with Test, Result, Reference range, Position and Flag columns."},{"id":"NFR-03","weight":"Could","text":"The booking wizard fits 390 px: progress list, a day strip that scrolls in its own box, slot buttons at least 44 px tall and a reachable footer bar with Back and Continue."},{"id":"NFR-04","weight":"Could","text":"The sign-in screen shows the Larkspur Health brand panel (headline \"Your health record, at your pace.\") and follows the system colour scheme; in dark mode text stays readable, and on a phone the brand panel stacks above the sign-in card."},{"id":"NFR-05","weight":"Should","text":"Data is kept per browser tab: a reload keeps changes, a new tab starts from the seeded record."}],"glossary":[{"term":"H / L flag","meaning":"H = result above the reference range, L = result below it."},{"term":"24-hour lock","meaning":"Visits starting less than 24 hours from the portal clock cannot be changed online."},{"term":"Lead time","meaning":"Slots are offered only if they start at least 60 minutes from the portal clock."}],"outOfScope":["Real telehealth video","Payments and statements","File upload contents (only name and size are checked)"]},{"ns":"freight","title":"Tidemark Logistics","version":"1.1","summary":"Tidemark Logistics is a freight shipment console. Dispatchers sign in, browse and search shipments with status tabs and exception flags, open a detail page (route map, hub-local timeline, parcel weights, customs documents), cancel shipments, book new ones through a four-step wizard with service quotes and optional insurance, and print a shipping label. The console clock is fixed at 19 Oct 2026.","problem":"Dispatchers need to trust the tracking data they act on: which shipments are late or held, what a parcel is charged for, and what a booking will cost.","personas":[{"name":"Dispatcher","need":"Find a shipment fast, see whether it is late or held and resolve customs holds."},{"name":"Shipper","need":"Book a shipment and know the price, ETA and insurance before confirming."},{"name":"Integration client","need":"Sign in with a service key instead of a person."}],"goals":["Weights, dimensional weight and prices are computed with one documented formula.","Exceptions and Late flags appear only when they are actually open.","The timeline always reads newest first, in each hub's local time.","A booking cannot be made with invalid parties, parcels or insurance values."],"features":[{"id":"AUT","title":"Sign-in","summary":"Email and password, or an API key.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Valid credentials open the Shipments page and show the signed-in identity in the sidebar.","accept":["Heading \"Sign in to Tidemark\" with Email, Password and Sign in","Sidebar shows \"Mara Ellison\" and \"Dispatcher\""]},{"id":"FR-AUT-02","weight":"Should","text":"Wrong credentials and blank submissions are refused without leaving the sign-in page.","accept":["\"Email or password is incorrect\"","\"Enter your work email and password\""]},{"id":"FR-AUT-03","weight":"Could","text":"\"Use an API key instead\" signs in with a valid key and rejects an unknown one.","accept":["\"That API key was not recognised\"","Valid key: sidebar shows \"API client\" and \"Service key\""]},{"id":"FR-AUT-04","weight":"Could","text":"Sign out returns to the sign-in page and Back does not reveal shipments."}]},{"id":"LST","title":"Shipments list","summary":"Tabs, search, sort and pagination over 30 seeded shipments.","reqs":[{"id":"FR-LST-01","weight":"Must","text":"The list opens on All with exact tab counts and a ten-row first page.","accept":["All 30, Booked 3, In transit 12, At customs 3, Out for delivery 3, Delivered 7, Cancelled 2, Exceptions 6","\"Page 1 of 3\"; columns Tracking, Route, Service, Status, ETA, Weight; first row TMK-480601 (ETA 13 Oct 2026)"]},{"id":"FR-LST-02","weight":"Should","text":"A status tab narrows the table to that status.","accept":["Delivered: seven rows, all \"Delivered\", one page","Cancelled: TMK-480466 and TMK-480656"]},{"id":"FR-LST-03","weight":"Must","text":"The Exceptions tab lists exactly the shipments with an open exception, each with its reason.","accept":["TMK-480377, TMK-480444, TMK-480523, TMK-480589, TMK-480623, TMK-480678","TMK-480377 flag: \"Held at customs: Commercial invoice missing\""]},{"id":"FR-LST-04","weight":"Should","text":"Search matches tracking number or consignee, case-insensitively; no match shows an empty state.","accept":["\"480377\" returns only TMK-480377 (At customs)","\"oakmere\" returns TMK-480477 and TMK-480612 (Oakmere Stores)","\"zzz-none\": \"No shipments match your search\"; clearing the box restores ten rows"]},{"id":"FR-LST-05","weight":"Could","text":"The ETA header toggles ascending (default) and descending order.","accept":["Ascending first row TMK-480601 (13 Oct 2026)","Descending first row TMK-480433 (30 Oct 2026)"]},{"id":"FR-LST-06","weight":"Could","text":"Pagination moves across three pages with Previous and Next disabled at the ends."},{"id":"FR-LST-07","weight":"Should","text":"A shipment shows \"Late\" only when its ETA is before today and it is not delivered.","accept":["TMK-480444 (ETA 17 Oct) is Late","TMK-480589 (ETA 19 Oct, due today) is not Late"]}]},{"id":"DET","title":"Shipment detail","summary":"Summary, route map and timeline.","reqs":[{"id":"FR-DET-01","weight":"Must","text":"The detail page shows status, service, ETA, parties and a route map listing every hub with the current position marked.","accept":["TMK-480411: In transit, Standard, ETA 21 Oct 2026, Keiran to Brannhaven","Hubs in order KEI, QIN, SUN, HAL, ZEN, TAV, MRS, KLM, BRH; marker between ZEN and TAV"]},{"id":"FR-DET-02","weight":"Must","text":"The timeline lists events newest first.","accept":["TMK-480411 first entry \"Departed Zenith Dry Port\" (16 Oct), last \"Shipment booked\" (11 Oct)"]},{"id":"FR-DET-03","weight":"Should","text":"Each event shows the local time at its hub with the UTC offset.","accept":["\"Departed Zenith Dry Port\": 16 Oct, 14:00 UTC+03:00","\"Shipment booked\": 11 Oct, 15:00 UTC+09:00"]}]},{"id":"WGT","title":"Weights","summary":"kg/lb display and dimensional charging.","reqs":[{"id":"FR-WGT-01","weight":"Must","text":"The unit toggle converts kg to lb with 1 kg = 2.2046 lb, rounded (not truncated) to one decimal.","accept":["TMK-480411 kg: 12.5, 8.0, 2.4; total 22.9","lb: 27.6, 17.6, 5.3; total 50.5"]},{"id":"FR-WGT-02","weight":"Must","text":"Dimensional weight is length x width x height (cm) / 5000; each parcel is charged at the greater of actual and dimensional weight, and the shipment shows the chargeable total.","accept":["60x40x30 = 14.4 kg; 70x70x25 = 24.5 kg; 100x50x40 = 40.0 kg","Chargeable weight 78.9 kg; volumetric parcels tagged \"Volumetric\""]}]},{"id":"CUS","title":"Customs documents and exceptions","summary":"Checklist, holds and exception lifecycle.","reqs":[{"id":"FR-CUS-01","weight":"Should","text":"The checklist separates required and optional documents with a received count and shows the exception banner.","accept":["TMK-480377: \"2 of 3 required documents received\"; Commercial invoice unchecked, Packing list and Certificate of origin checked, Dangerous goods declaration (optional) unchecked","Banner \"Held at customs: Commercial invoice missing\""]},{"id":"FR-CUS-02","weight":"Must","text":"Receiving the last required document clears the hold, logs an event and removes the shipment from Exceptions.","accept":["\"3 of 3 required documents received\"; banner gone","Newest timeline entry \"Customs documents received, clearance resumed\"","Exceptions tab shows five rows"]},{"id":"FR-CUS-03","weight":"Must","text":"A delivered shipment carries no open exception and is excluded from the Exceptions tab; its past delay remains in the timeline.","accept":["TMK-480211: Delivered, no banner, timeline still lists \"Delayed: Storm closed the berth for 14 hours\"","Exceptions tab shows six rows without TMK-480211"]},{"id":"FR-CUS-04","weight":"Should","text":"Documents of a delivered shipment are locked.","accept":["Checkboxes disabled; \"Documents are locked once a shipment is delivered\""]}]},{"id":"CAN","title":"Cancellation","summary":"Cancel a shipment that has not been delivered.","reqs":[{"id":"FR-CAN-01","weight":"Must","text":"Cancelling asks for confirmation, then sets the status to Cancelled, logs the event and updates the tab counts.","accept":["Dialog with \"Keep shipment\" and \"Yes, cancel shipment\"","Status \"Cancelled\"; newest entry \"Shipment cancelled by shipper\"; Cancel button gone","TMK-480305: tabs Cancelled 3, In transit 11"]},{"id":"FR-CAN-02","weight":"Should","text":"\"Keep shipment\" closes the dialog and leaves the status unchanged."},{"id":"FR-CAN-03","weight":"Should","text":"A delivered shipment has no Cancel button."}]},{"id":"WIZ","title":"New shipment wizard","summary":"Four steps: sender, recipient, parcels, service.","reqs":[{"id":"FR-WIZ-01","weight":"Must","text":"A shipment can be booked end to end; it appears as Booked with the quoted ETA and a new tracking number.","accept":["Sender Brannhaven Port to recipient Halvard Terminal, one 5 kg 40x30x20 cm parcel, Standard","TMK-480901: Booked, route BRH, KLM, MRS, TAV, ZEN, HAL, ETA 23 Oct 2026, one entry \"Shipment booked\"","List shows All 31, Booked 4"]},{"id":"FR-WIZ-02","weight":"Should","text":"Each step blocks Continue and names the missing field.","accept":["\"Sender: name must be 2 to 60 characters\"","\"Sender: street address is required\""]},{"id":"FR-WIZ-03","weight":"Should","text":"Origin and destination hubs must differ.","accept":["\"Origin and destination hubs must differ\"; no shipment created"]},{"id":"FR-WIZ-04","weight":"Should","text":"A parcel may weigh at most 70 kg.","accept":["70 kg accepted","71 kg: \"Parcel 1: weight exceeds the 70 kg limit\""]},{"id":"FR-WIZ-05","weight":"Should","text":"Economy is unavailable when any parcel exceeds 30 kg.","accept":["35 kg parcel: Economy disabled with \"Parcel 1 is over 30 kg; Economy accepts up to 30 kg per parcel\"; Standard and Express selectable"]}]},{"id":"QUO","title":"Quotes and insurance","summary":"Price per service with fuel surcharge and optional insurance.","reqs":[{"id":"FR-QUO-01","weight":"Must","text":"Each service shows an ETA and a total of freight plus an 8% fuel surcharge on freight.","accept":["5 kg 40x30x20 cm: Economy ETA 26 Oct, Standard 23 Oct, Express 21 Oct 2026","Standard: Freight $36.00, Fuel surcharge (8%) $2.88, Total $38.88"]},{"id":"FR-QUO-02","weight":"Must","text":"Insurance costs 1.5% of the declared value and is added to every total after the surcharge.","accept":["Declared value 1500: insurance $22.50","Totals Economy $48.42, Standard $61.38, Express $88.38"]},{"id":"FR-QUO-03","weight":"Must","text":"The declared value must be between $1 and $50,000; outside that range no insured quote is produced.","accept":["60000 and 0: \"Declared value must be between $1 and $50,000\"","50000 accepted; insurance row $750.00"]}]},{"id":"LBL","title":"Shipping label","summary":"Printable label for a shipment.","reqs":[{"id":"FR-LBL-01","weight":"Could","text":"The label shows tracking, routing, service, parcel count, weight in both units, ETA and a barcode number.","accept":["TMK-480411: \"KEI > BRH\", Standard, 3 parcels, 22.9 kg (50.5 lb), ETA 21 Oct 2026","Barcode number 9248041103"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"Data is held per browser tab; \"Reset demo data\" (confirmed with \"Yes, reset data\") restores the 30 seeded shipments.","accept":["After cancelling TMK-480305 and resetting: All 30 and TMK-480305 In transit"]}],"glossary":[{"term":"Chargeable weight","meaning":"Per parcel, the greater of actual weight and dimensional weight; summed for the shipment."},{"term":"Dimensional weight","meaning":"Length x width x height in cm divided by 5000, in kg."},{"term":"Exception","meaning":"An open problem on an undelivered shipment, such as a customs hold."}],"outOfScope":["Real carrier integrations","Payments","Printing hardware"]},{"ns":"mail","title":"Postwell","version":"4.1","summary":"Postwell is a web email client with a folder and label sidebar showing unread counts, threaded conversations, star and flag markers, bulk actions, search operators, a compose window with recipient chips, Cc/Bcc, attachments, draft autosave and a 5-second undo-send, snooze with presets and a date picker, and settings for signature, vacation responder and labels. The product clock is fixed at Monday 19 Oct 2026 09:30 UTC.","problem":"People who handle a busy inbox need to triage quickly, find mail precisely and send messages with confidence, and must be able to trust the unread counts, the folder contents and who actually sees a message.","personas":[{"name":"Busy professional","need":"Triage the inbox with bulk actions, snooze and keyboard shortcuts."},{"name":"Sender","need":"Compose, attach, save drafts and retract a message within the undo window."},{"name":"Organiser","need":"Keep mail sorted with labels and find anything with search operators."}],"goals":["Badges and unread counts always match the number of unread conversations they describe.","Every selected conversation is acted on; none is silently skipped.","Blind-copied recipients are never revealed to other recipients.","Invalid input is rejected with a specific message before anything is sent or saved."],"features":[{"id":"AUTH","title":"Sign-in","summary":"Access to the mailbox.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"Valid credentials open the Inbox with the seeded folder counts.","accept":["The sign-in page is headed \"Sign in to Postwell\" with Email and Password fields","After sign-in the URL is /mail/inbox, the heading is \"Inbox\" with \"9 unread\"","Sidebar badges: Inbox 9, Drafts 2, Spam 1","The browser tab title starts with \"Inbox (9)\" and ends with \"Postwell\""]},{"id":"FR-AUTH-02","weight":"Should","text":"Empty or wrong credentials are rejected without leaving the sign-in page.","accept":["Empty form: \"Enter your email and password\"","Wrong password: \"Invalid email or password\" and the form stays"]}]},{"id":"LST","title":"Folders and lists","summary":"Sidebar folders, list rows and paging.","reqs":[{"id":"FR-LST-01","weight":"Should","text":"Every sidebar folder opens its own URL and lists its seeded conversations.","accept":["Sent (/mail/sent): 6 conversations including \"Meeting notes follow-up\"","Drafts: \"Thank you for the intro\" and one with no subject","Archive: \"Signed: office lease addendum\" and \"Trip recap: Lisbon\"","Spam: \"You have won a free cruise!!!\" and \"Limited offer: cheap watches\"","Trash: \"Old newsletter: September edition\" and an \"Empty Trash now\" button","Starred: \"Re: Vendor shortlist for the print run\", \"Photos from Saturday\", \"Dinner reservation confirmed\""]},{"id":"FR-LST-02","weight":"Could","text":"List rows show unread (bold), important marker, label chips, attachment paperclip, message count, filled star and a date that is a time for today, \"Yesterday\" or a weekday label.","accept":["\"Daniel Moss, Q4 roadmap review — agenda\": bold, important marker, \"Work\" chip, count 2","\"Stayfold Billing, Invoice 4471 for October hosting\": paperclip and \"Receipts\" chip, time not weekday","\"Re: Vendor shortlist for the print run\": not bold, star filled"]},{"id":"FR-LST-03","weight":"Should","text":"The Inbox pages in groups of 25.","accept":["\"Page 1 of 2 · 27 conversations\" with the older-page button enabled","Page 2 shows 2 rows and the next button is disabled"]},{"id":"FR-LST-04","weight":"Could","text":"Direct URLs open the right folder after sign-in; browser Back and Forward work inside the app."}]},{"id":"RD","title":"Reading and markers","summary":"Open conversations, read state, star and flag.","reqs":[{"id":"FR-RD-01","weight":"Must","text":"Opening an unread conversation marks it read and lowers the Inbox badge and the header unread count by one each time.","accept":["Opening \"Lunch on Thursday?\": row no longer bold; Inbox badge and header go from 9 to 8","Opening \"Contract draft needs your signature\" next gives 7","The pane shows Reply, Reply all and Forward"]},{"id":"FR-RD-02","weight":"Should","text":"Mark as unread / Mark as read change the Inbox badge by one per conversation.","accept":["Mark \"Photos from Saturday\" unread: row bold, badge and header 10; Mark as read: 9","Marking \"Lunch on Thursday?\" read: 8"]},{"id":"FR-RD-03","weight":"Could","text":"Starring a conversation adds it to Starred and unstarring removes it.","accept":["Starring \"Receipt for your coffee order\" makes Starred 4; unstarring \"Photos from Saturday\" leaves 3"]}]},{"id":"ACT","title":"Archive, spam and trash","summary":"Move conversations between folders, singly or in bulk.","reqs":[{"id":"FR-ACT-01","weight":"Must","text":"Archive removes the row from the Inbox with the toast \"Conversation archived\" and an Undo that restores it.","accept":["Archiving \"Parking permit renewal\", then Undo, restores the row; archiving again leaves Archive with 3 conversations"]},{"id":"FR-ACT-02","weight":"Must","text":"A bulk action applies to every selected conversation.","accept":["Selecting \"Library books due soon\", \"Weekend plans\" and \"Parking permit renewal\" and archiving removes all three from the Inbox","Archive then lists 5 conversations (the 3 plus the 2 seeded)"]},{"id":"FR-ACT-03","weight":"Must","text":"Move to Trash holds mail until Empty Trash is confirmed; cancelling keeps it.","accept":["Toast \"Conversation moved to Trash\"; Trash lists \"Weekend plans\" and \"Old newsletter: September edition\"","\"Empty Trash now\" opens a confirmation; Cancel keeps both","Confirming empties Trash and shows its empty-state message; \"Weekend plans\" is not in the Inbox"]},{"id":"FR-ACT-04","weight":"Should","text":"Report spam moves the conversation to Spam with toast \"Marked as spam\"; Spam badge counts only unread items.","accept":["\"Receipt for your coffee order\" leaves the Inbox; Spam lists 3 while its badge stays 1"]}]},{"id":"SNZ","title":"Snooze","summary":"Hide a conversation until a chosen time.","reqs":[{"id":"FR-SNZ-01","weight":"Should","text":"Snoozing with a preset hides the row until the chosen time and lists it under Snoozed.","accept":["The menu offers presets and \"Pick date and time (UTC)\"","Toast starts \"Snoozed until\" and ends \"UTC\"","Snoozed lists \"Passport renewal form\" (due Wed, Oct 21, 8:00 AM) and the new item"]},{"id":"FR-SNZ-02","weight":"Should","text":"A snooze time must be in the future relative to the product clock.","accept":["2026-10-18 08:00 shows \"Snooze time must be in the future\" and the row stays in the Inbox"]}]},{"id":"LBL","title":"Labels","summary":"Apply labels and manage them in Settings.","reqs":[{"id":"FR-LBL-01","weight":"Should","text":"Labels are applied and removed from the \"Label as\" menu and filter the list from the sidebar.","accept":["Menu lists Work, Personal, Receipts, Travel and Team","Choosing Personal adds a green chip; the Personal sidebar list includes the conversation; removing it deletes the chip"]},{"id":"FR-LBL-02","weight":"Should","text":"Labels can be created with a colour and deleted after confirmation.","accept":["Settings > Labels lists Work, Personal, Receipts, Travel, Team","Creating \"Clients\" in Red shows toast \"Label created\" and it is the sixth label","Delete opens \"Delete label?\" (the label will be removed from all conversations); confirming shows \"Label deleted\" and five labels remain"]},{"id":"FR-LBL-03","weight":"Could","text":"Label names are required, unique (case-insensitive) and at most 20 characters.","accept":["Empty: \"Label name is required\"","\"work\": A label named \"work\" already exists","A 23-character name: \"Label name can be at most 20 characters\""]}]},{"id":"SRC","title":"Search","summary":"Free text and operators from:, to:, subject:, label:, has:attachment, is:unread.","reqs":[{"id":"FR-SRC-01","weight":"Must","text":"Search operators narrow the mailbox to exactly the matching conversations, and operators combine.","accept":["from:priya: \"Q4 roadmap review — agenda\", \"Budget v3 — comments inside\", \"Welcome aboard, Lucas\"","is:unread: 9 conversations","subject:invoice: \"Invoice 4471 for October hosting\"","Free text \"budget\": \"Budget v3 — comments inside\" and \"Re: Hiring plan for Q1\""]},{"id":"FR-SRC-02","weight":"Could","text":"A search with no matches shows an empty state, not an error; clearing the box restores the Inbox of 27 conversations."},{"id":"FR-SRC-03","weight":"Must","text":"has:attachment returns only conversations that have an attachment.","accept":["7 conversations, each with a paperclip, including \"Floor plan v3 for the new studio\" and \"Signed: office lease addendum\"; \"Lunch on Thursday?\" is not listed","from:leo has:attachment returns only \"Team offsite photos (zip)\""]}]},{"id":"CMP","title":"Compose and send","summary":"New messages, undo send, validation, attachments, drafts, reply and Bcc.","reqs":[{"id":"FR-CMP-01","weight":"Must","text":"A composed message is sent after a 5-second undo countdown and appears in Sent with the signature.","accept":["Compose (button or \"c\") opens with the body ending \"-- \" and the signature name","Send shows a toast counting down 5 seconds with Undo","After the countdown the message is the newest in Sent and Sent grows from 6 to 7; the body has the typed text and the signature"]},{"id":"FR-CMP-02","weight":"Should","text":"Undo within the countdown cancels the send and returns to the compose window with the same content.","accept":["No conversation with that subject appears in Sent; Sent still has 6"]},{"id":"FR-CMP-03","weight":"Must","text":"Sending is refused with a specific message when recipients, content or addresses are invalid.","accept":["No recipient: \"Add at least one recipient\"","Malformed chip text: “not-an-email” is not a valid email address","No subject and no body: \"Add a subject or a message before sending\""]},{"id":"FR-CMP-04","weight":"Should","text":"Attachments over 10 MB or more than 5 files at once are refused.","accept":["A large file shows that it \"is larger than 10.0 MB\" and is not attached","Six files show \"You can attach at most 5 files\""]},{"id":"FR-CMP-05","weight":"Should","text":"Typing autosaves a draft after about 2 seconds; the draft can be reopened and discarded.","accept":["Status reads \"Draft saved\"; \"Save and close\" adds the draft so Drafts shows 3 and its badge is 3","Reopening shows the saved subject; \"Discard draft\" returns Drafts to 2"]},{"id":"FR-CMP-06","weight":"Must","text":"Reply addresses the sender and adds the message to the same conversation.","accept":["Reply to \"Lunch on Thursday?\" opens a box addressed to Hana Kobayashi with subject \"Re: Lunch on Thursday?\"","After sending, the conversation has 2 messages and the latest is from the signed-in user"]},{"id":"FR-CMP-07","weight":"Must","text":"Bcc recipients receive the message but are never shown to other recipients.","accept":["In Sent the To line lists only the To recipients; the Bcc address does not appear there"]}]},{"id":"SET","title":"Settings","summary":"Signature and vacation responder.","reqs":[{"id":"FR-SET-01","weight":"Should","text":"The signature is saved, appended to new messages, and limited to 200 characters.","accept":["Settings (/mail/settings/general) shows the Signature tab and a counter \"n / 200\"","Saving shows toast \"Signature saved\"; new Compose bodies end with \"-- \" and the new signature","201 characters turns the counter red and shows \"Signature can be at most 200 characters\""]},{"id":"FR-SET-02","weight":"Must","text":"An enabled vacation responder requires a subject, a message and a first day, and the last day must not be before the first day.","accept":["Messages in order: \"Vacation subject is required\", \"Vacation message is required\", \"Choose the first day\"","First day 2026-10-30 with last day 2026-10-26 shows \"Last day must be on or after the first day\" and nothing is saved","First 2026-10-26 and last 2026-10-30 saves with toast \"Vacation responder is on\""]}]},{"id":"KEY","title":"Keyboard shortcuts","summary":"Act on the list without a mouse.","reqs":[{"id":"FR-KEY-01","weight":"Should","text":"\"?\" opens \"Keyboard shortcuts\"; j moves down, o opens, e archives, c composes.","accept":["j three times then o opens the third conversation \"Lunch on Thursday?\"; e shows \"Conversation archived\""]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"At 800 px width the sidebar narrows or collapses and nothing overflows horizontally; the account menu (name, email, Sign out) stays in the viewport; dark mode keeps the teal accent and unread rows stand out."},{"id":"NFR-02","weight":"Must","text":"Unread badges, header counts and the tab title always equal the number of unread conversations in the folder they describe."}],"glossary":[{"term":"Conversation","meaning":"A thread of one or more messages shown as one row."},{"term":"Undo window","meaning":"The 5 seconds after Send during which the message can still be cancelled."},{"term":"Product clock","meaning":"Time is fixed at Mon 19 Oct 2026 09:30 UTC for every date rule."}],"outOfScope":["Real mail delivery","Mobile apps"]},{"ns":"crm","title":"Pipewise","version":"4.0","summary":"Pipewise is a sales pipeline CRM. Teams track deals on a six-stage board with weighted totals, work a deal through its detail page, manage accounts and contacts (including duplicate detection and merge), price deals in four currencies and compare closed plus weighted pipeline with quota on a forecast view. The demo clock is pinned to 19 Oct 2026.","problem":"Sales managers need to trust the numbers on screen: what a stage is worth, which deals are late, what each currency converts to and whether the quarter will reach quota.","personas":[{"name":"Sales manager","need":"See the whole pipeline, move deals between stages and judge forecast against quota."},{"name":"Quota-carrying rep","need":"Log activity, keep the next step current and see only what is overdue."},{"name":"Sales operations","need":"Keep accounts and contacts clean by merging duplicates."}],"goals":["Every column total, weighted total and forecast figure reconciles to the deals behind it.","A deal closing today is never shown as overdue; one closing yesterday always is.","Foreign-currency amounts are converted at the published rate, and merges never lose data."],"features":[{"id":"AUT","title":"Sign-in and session","summary":"Email and password sign-in; sign-out closes the session.","reqs":[{"id":"FR-AUT-01","weight":"Must","text":"Sign-in rejects missing and wrong credentials with distinct messages and opens the board for valid ones.","accept":["Both fields empty: \"Enter your email and password\" and the login form stays","Wrong password: \"Invalid credentials\" and no board is shown","A valid rep account opens the board and the account menu shows the rep name"]},{"id":"FR-AUT-02","weight":"Should","text":"Sign out returns to the sign-in form and protected pages stay closed afterwards.","accept":["Opening the forecast page signed out shows the sign-in form","Browser Back after sign-out shows no deal data"]},{"id":"FR-AUT-03","weight":"Could","text":"The sign-in screen shows the brand panel (headline \"See every deal before it slips.\") beside a form with a Show/Hide password toggle.","accept":["Clicking Show reveals the password and the button reads \"Hide\"","No text is clipped or overlapping"]}]},{"id":"BRD","title":"Pipeline board","summary":"Six stage columns with counts, totals and weighted totals; deals move by drag or keyboard.","reqs":[{"id":"FR-BRD-01","weight":"Must","text":"The board shows six columns in order with their deal counts and an open-pipeline summary line.","accept":["Seeded counts: Prospect 5, Qualified 6, Proposal 7, Negotiation 6, Closed won 4, Closed lost 2","Summary reads \"24 open deals · $2,708,184 total · $1,162,131 weighted\""]},{"id":"FR-BRD-02","weight":"Must","text":"Each column shows a Total (sum of deal amounts in USD) and a Weighted total (sum of amount x stage probability). Probabilities are Prospect 10%, Qualified 30%, Proposal 50%, Negotiation 75%, Closed won 100%, Closed lost 0%.","accept":["Prospect $400,806 / $40,081; Qualified $803,620 / $241,086; Proposal $987,418 / $493,709; Negotiation $516,340 / $387,255","Closed won $182,760 / $182,760; Closed lost $58,380 / $0","For open stages below 100% the weighted total is lower than the total"]},{"id":"FR-BRD-03","weight":"Must","text":"Dragging a card onto another column moves the deal and recalculates both columns.","accept":["Toast \"<deal> moved to <stage>\"","Moving \"Wholesale portal\" from Prospect to Qualified gives Prospect 4 deals ($359,806 / $35,981) and Qualified 7 deals ($844,620 / $253,386)","The move is still there after a reload in the same tab"]},{"id":"FR-BRD-04","weight":"Should","text":"A focused card moves one stage right or left with Shift+ArrowRight / Shift+ArrowLeft.","accept":["The card's accessible name ends with \"Shift plus arrow keys move between stages.\"","Toast \"<deal> moved to <stage>\" and the target column count increases by one"]},{"id":"FR-BRD-05","weight":"Should","text":"Moving a deal to Closed lost requires a reason.","accept":["Dialog \"Mark deal as lost\" with a \"Lost reason\" select","Confirming without a reason shows \"Choose a reason for losing this deal\" and the dialog stays open","Escape or Cancel closes the dialog and leaves the deal where it was","With a reason chosen the card appears in Closed lost"]},{"id":"FR-BRD-06","weight":"Could","text":"The top search and the Owner select narrow the cards and the stage counts.","accept":["Search \"Brightloom\" leaves two cards","Owner \"Ravi Menon\" gives Prospect 1, Qualified 0, Proposal 2, Negotiation 1, Closed won 2, Closed lost 0"]},{"id":"FR-BRD-07","weight":"Must","text":"A card whose close date is before the demo date (19 Oct 2026) shows the date in red with an \"<n>d overdue\" pill, where n is the number of days late. A deal closing on the demo date is \"Due today\" and is not overdue.","accept":["Close 12 Oct shows \"7d overdue\"; 16 Oct \"3d overdue\"; 5 Oct \"14d overdue\"","Close 19 Oct shows the chip \"Due today\", a non-red date and no overdue pill"]},{"id":"FR-BRD-08","weight":"Could","text":"Cards show the native amount, the converted USD value muted beside it, the overdue state and the owner initials, in a flat style with aligned digits.","accept":["A EUR card reads \"€38,500\" with \"≈ $41,580\"","No shadows at rest; numbers use tabular digits"]}]},{"id":"DEL","title":"Deal detail and editing","summary":"Create, read, edit, delete and annotate a deal.","reqs":[{"id":"FR-DEL-01","weight":"Should","text":"The deal detail page shows amount, stage with probability, weighted value, close date status, contacts, next step and an activity timeline (newest first).","accept":["D-1001 \"Warehouse analytics rollout\": Amount $84,000, Negotiation (75% probability), Weighted value $63,000, Close date 6 Nov 2026 \"On schedule\"","Contacts Hannah Weiss (VP Operations) and Ines Carvalho (Buyer); next step \"Send revised MSA to legal\""]},{"id":"FR-DEL-02","weight":"Must","text":"Logging an activity adds it to the top of the timeline, and the next step can be edited; both persist after reload.","accept":["Empty activity text shows \"Write a short summary of the activity\" and adds nothing","Toast \"Activity logged\"; new entry is first in the timeline with its type","Toast \"Next step saved\"; the card shows the new text after a reload"]},{"id":"FR-DEL-03","weight":"Should","text":"The New deal form validates each field and reports its own message.","accept":["\"Deal name is required\", \"Deal name must be at least 3 characters\"","\"Amount is required\", \"Amount must be greater than 0\", \"Amount must be a positive number\" for non-numeric input, \"Amount can't exceed 100,000,000\"","\"Close date is required\", \"Close date must be a valid date (YYYY-MM-DD)\"","\"Choose an account\" when no account is selected","Escape closes the dialog without creating a deal"]},{"id":"FR-DEL-04","weight":"Must","text":"Creating a valid deal places it in Prospect, converts the amount to USD at the published rate and owns it by the account owner.","accept":["Toast \"Deal created\" and the new deal's detail page opens","EUR 20,000 shows \"≈ $21,600 at 1.08\", Stage Prospect (10% probability), Weighted value $2,160","Prospect then shows 6 deals, Total $422,406, Weighted $42,241","Amount 1 is accepted with Weighted value $0"]},{"id":"FR-DEL-05","weight":"Should","text":"Editing the close date updates the overdue state and records the change in the timeline.","accept":["Setting 2026-10-10 shows \"10 Oct 2026\" in red with \"9d overdue\"","Setting 2026-10-19 shows \"On schedule\" with no overdue pill","Timeline top entry \"Close date changed from 2026-10-30 to 2026-11-30\""]},{"id":"FR-DEL-06","weight":"Should","text":"Deleting a deal asks for confirmation; confirming removes it everywhere.","accept":["Dialog \"Delete this deal?\" warns that activity history will be removed; Cancel keeps the deal","Confirming shows \"Deal deleted\" and the board shows 23 open deals","Reopening the deal shows \"Deal not found\" with a \"Back to pipeline\" button"]},{"id":"FR-DEL-07","weight":"Could","text":"Changing the stage on the detail page moves the deal and updates probability and weighted value.","accept":["\"Ad ops dashboard\" Qualified (30%, $7,200) moved to Proposal shows toast \"Moved to Proposal\", 50% and $12,000"]}]},{"id":"FXC","title":"Currencies","summary":"Deals are priced in USD, EUR, GBP or JPY and converted to USD at published rates.","reqs":[{"id":"FR-FXC-01","weight":"Must","text":"Every foreign-currency amount converts to USD as amount x the published rate: USD 1, EUR 1.08, GBP 1.27, JPY 0.0067. The rate printed beside a converted value is the rate used.","accept":["EUR 38,500 gives \"≈ $41,580 at 1.08\"","GBP 22,400 gives \"≈ $28,448 at 1.27\"","JPY 8,600,000 gives \"≈ $57,620 at 0.0067\""]},{"id":"FR-FXC-02","weight":"Should","text":"The Forecast page lists the exchange rates table with the four published rates.","accept":["USD 1, EUR 1.08, GBP 1.27, JPY 0.0067"]}]},{"id":"ACC","title":"Accounts","summary":"Sortable, searchable, paged list of 30 accounts with a detail page.","reqs":[{"id":"FR-ACC-01","weight":"Must","text":"Accounts page ten at a time; each page starts exactly after the previous one ends, with no repeated or skipped rows.","accept":["\"30 accounts\", \"Showing 1–10 of 30\", \"Page 1 of 3\"; Previous disabled on page 1, Next disabled on page 3","Page 1 runs Ashgrove Energy to Granite Peak Outdoors; page 2 starts with Halden Freight; page 3 ends with Vantor Analytics"]},{"id":"FR-ACC-02","weight":"Should","text":"Column headers sort the whole list (not just the visible page), toggle direction on a second click and reset to page 1.","accept":["Default Account ascending (aria-sort \"ascending\")","Open pipeline descending: Meridian Cold Chain $410,000, Copperfield Mining $310,000, Cobalt Harbour Marine $240,000, Kingfisher Aviation Services $205,000","Open pipeline ascending starts with Fernleaf Organics, shown as \"—\"","Owner ascending starts with Cobalt Harbour Marine (Amara Nwosu)"]},{"id":"FR-ACC-03","weight":"Could","text":"Search filters accounts; no match shows an empty state.","accept":["\"logistics\" gives 3 accounts: Halden Freight, Harrow Logistics, Meridian Cold Chain","\"zzzz\" shows \"No accounts match “zzzz”.\""]},{"id":"FR-ACC-04","weight":"Could","text":"The account detail page shows open deal count, open pipeline, deals and contacts; an unknown id shows a not-found state.","accept":["Brightloom Textiles: Open deals 2, Open pipeline $96,000, two deals, contacts Hannah Weiss and Ines Carvalho","\"Account not found\" with a \"Back to accounts\" button"]}]},{"id":"CON","title":"Contacts and duplicates","summary":"Possible duplicate pairs are detected and can be merged or dismissed.","reqs":[{"id":"FR-CON-01","weight":"Must","text":"Duplicate detection flags pairs sharing an email, a phone number, or the same name at the same account, and labels the reason.","accept":["Subtitle \"43 contacts · 3 possible duplicate pairs\"","Marcus Doyle \"Same email address\"; Sofia Lindqvist \"Same name at the same account\"; Tomás Ribeiro \"Same phone number\""]},{"id":"FR-CON-02","weight":"Must","text":"Merging keeps the chosen record and fills any missing field (such as phone or title) from the other record; no data is lost.","accept":["Dialog \"Merge contacts\" with a \"Keep this record\" choice; Cancel changes nothing","Merging Sofia Lindqvist (keeping the record with no phone or title) shows \"Merged into Sofia Lindqvist\"","The surviving contact is Head of Retail, +33 1 55 55 01 99; counts become \"42 contacts · 2 possible duplicate pairs\""]},{"id":"FR-CON-03","weight":"Could","text":"\"Not a duplicate\" removes the pair from the list without deleting either contact.","accept":["Toast \"Marked as not a duplicate\"; pairs drop to 2; all contacts stay at 43"]}]},{"id":"FCS","title":"Forecast","summary":"Q4 closed won plus weighted pipeline versus quota.","reqs":[{"id":"FR-FCS-01","weight":"Must","text":"Forecast = closed won + weighted pipeline; attainment is closed won / quota and forecast vs quota is forecast / quota, using the $1,000,000 team quota (monthly quota $333,333).","accept":["Closed won $182,760 (18.3% attainment); Weighted pipeline $924,931; Forecast $1,107,691; Forecast vs quota 110.8%","October closed $182,760, weighted $343,224; November weighted $339,105; December weighted $242,602","By rep forecasts: Ravi Menon $258,740 (107.8%), Elena Park $255,075 (115.9%), Tobias Grant $220,670 (110.3%), Amara Nwosu $233,582 (129.8%), Lucas Ferreira $139,624 (87.3%)"]},{"id":"FR-FCS-02","weight":"Could","text":"The chart shows October, November and December as stacked bars (closed won plus weighted) with a dashed quota line at $333,333.","accept":["October is the tallest bar","Axis labels are at least 12px and nothing is clipped"]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"Unknown routes show \"Page not found\" (\"The page you are looking for does not exist.\") with a \"Back to pipeline\" button."},{"id":"NFR-02","weight":"Could","text":"At 390px width there is no horizontal page scroll, gutters are 16px, the sidebar becomes a drawer opened by \"Open navigation\", and board columns scroll inside their own area.","accept":["Drawer lists Pipeline, Accounts, Contacts, Forecast and closes after a choice"]},{"id":"NFR-03","weight":"Could","text":"At 768px width the deal detail reflows without overflow and the title, stage select, Edit and Delete remain visible."},{"id":"NFR-04","weight":"Could","text":"With a dark colour scheme all screens use dark surfaces with legible text, and the overdue red keeps sufficient contrast."},{"id":"NFR-05","weight":"Should","text":"Application state is kept per browser tab: changes survive a reload in the same tab, and a new tab starts from the seeded book."}],"glossary":[{"term":"Weighted total","meaning":"Sum of amount x stage probability for the deals in a column, in USD."},{"term":"Overdue","meaning":"Close date strictly before the demo date of 19 Oct 2026."},{"term":"Forecast","meaning":"Closed won plus weighted pipeline for the quarter."}],"outOfScope":["Email sync","Import and export","Role permissions beyond sign-in"]},{"ns":"ledger","title":"Quillbook","version":"1.0","summary":"Quillbook is an invoicing tool for small studios. Staff keep a customer directory in USD, EUR or GBP, draft and send invoices with line discounts, tax and shipping, record payments and credit notes, void invoices, and see what is owed on an Overview and an Aging report.","problem":"A studio owner needs to know, to the cent, who owes what and how late it is. Totals, statuses and receivable figures must agree on every screen, and money actions must be deliberate and reversible only where the product says so.","personas":[{"name":"Owner","need":"See receivables at a glance, issue and send invoices, and be the only role able to void an invoice."},{"name":"Bookkeeper","need":"Draft invoices, record payments and credit notes and chase overdue balances, without the ability to void."}],"goals":["Every invoice total follows one fixed formula, so the same invoice shows the same figures everywhere.","An invoice status always reflects the money still owed.","Receivable figures on the Overview, customer pages and Aging report reconcile to each other.","Destructive actions need confirmation and respect role limits."],"features":[{"id":"AUTH","title":"Sign in and roles","summary":"Two demo roles, Owner and Bookkeeper, sign in with a username and password.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"Valid credentials open the Overview and show who is signed in and with which role.","accept":["The sign-in screen is headed \"Sign in to Quillbook\" with Username and Password fields and a line listing the two demo accounts with their roles","After sign-in the heading \"Overview\" is shown and the sidebar shows the full name with the role, for example \"Maren Holt\" and \"Owner\"","The sidebar footer reads \"Today is 19 Oct 2026\"; all day counts are measured from that business date"]},{"id":"FR-AUTH-02","weight":"Should","text":"Bad or missing credentials keep the user on the sign-in screen with a clear message.","accept":["A wrong password shows the red message \"Incorrect username or password\" and the heading stays \"Sign in to Quillbook\"","Signing in again with the right password opens the Overview and clears the message","Submitting both fields empty shows \"Enter your username and password\" without leaving the screen"]}]},{"id":"OVR","title":"Overview","summary":"A single screen of headline receivables figures and invoice counts by status.","reqs":[{"id":"FR-OVR-01","weight":"Must","text":"The Overview totals every unpaid balance in USD and splits out overdue, collected this month and drafts.","accept":["Outstanding receivables is $33,608.75 \"across 17 open invoices\"","Overdue $22,438.15; Collected in October +$4,500.00; Drafts (3) $3,650.80","A caption reads \"67% of the outstanding balance is overdue.\"","Void invoices and drafts are never part of Outstanding receivables"]},{"id":"FR-OVR-02","weight":"Should","text":"Invoices by status lists the six statuses with counts and a distinct chip colour each; a row opens the Invoices list filtered to that status.","accept":["Seeded counts: Draft 3, Sent 4, Partially paid 1, Paid 19, Overdue 12, Void 2","Draft and Void are grey, Sent blue, Partially paid amber, Paid green, Overdue red, each chip with a small dot before its label","Clicking the Overdue row opens Invoices filtered to Overdue with the subtitle \"12 invoices shown\""]},{"id":"FR-OVR-03","weight":"Could","text":"A thin bar under the receivables figures fills to the overdue share of the outstanding balance.","accept":["With the seeded books the red fill covers about 67% of the track and the rest is a pale track"]}]},{"id":"LST","title":"Invoice list","summary":"All invoices, newest first, with status chips, search and paging.","reqs":[{"id":"FR-LST-01","weight":"Should","text":"Status chips narrow the list and always show the counts for the full book.","accept":["Chips read All 41, Draft 3, Sent 4, Partially paid 1, Paid 19, Overdue 12, Void 2","The Overdue chip shows \"12 invoices shown\" with QB-1032 first and only red Overdue chips","The Void chip shows QB-1027 and QB-1026; the chip counts do not change when a filter is applied"]},{"id":"FR-LST-02","weight":"Should","text":"The search box matches invoice numbers and customer names, case-insensitively, and an empty result shows a helpful empty state.","accept":["\"halden\" shows \"3 invoices shown\": QB-1036, QB-1021 and QB-1015","\"QB-1029\" leaves one row: Orchard Row Press, £3,200.00 total, £3,000.00 balance","A search with no match shows \"No invoices match\", the hint \"Try another status or clear the search.\" and the subtitle \"0 invoices shown\""]},{"id":"FR-LST-03","weight":"Could","text":"The list shows 15 rows per page and pages forward and back.","accept":["Page 1 reads \"Showing 1–15 of 41\", starts at QB-1041 and has Previous disabled","Next shows \"Showing 16–30 of 41\" starting at QB-1026, then \"Showing 31–41 of 41\" ending at QB-1001 with Next disabled"]},{"id":"FR-LST-04","weight":"Should","text":"Overdue, Void and Paid invoices are visibly distinct, with days late shown for overdue ones.","accept":["An invoice due 19 Sep 2026 shows \"30 days late\" under its due date, a red Overdue chip, and \"30 days past due\" on its detail page","A void invoice has its number and total struck through, an em dash for Balance and a grey Void chip; its detail shows a VOID stamp and Balance due $0.00 with no Record payment button","A fully paid invoice shows a green PAID stamp, \"Paid\" equal to the total and Balance due $0.00, with its payment listed with date, method and reference"]}]},{"id":"INV","title":"Invoice editor","summary":"Create, edit, send, duplicate and delete invoices with live totals.","reqs":[{"id":"FR-INV-01","weight":"Must","text":"A new invoice with several lines, a discount, tax and shipping gets the right totals and can be saved and sent.","accept":["The editor says \"Amounts are in USD. Shipping is not taxed.\"; choosing a customer shows \"<Customer> pays in USD on Net 15 terms.\" and sets the due date from the issue date plus the terms (3 Nov 2026 for Net 15)","A line of 2 at 150.00 with Tax 10% shows $300.00; a line of 10 at 4.50 with Tax 0% and 10% line discount shows $40.50","With invoice discount 10% and Shipping 12.00 the summary reads Subtotal $340.50; Discount -$34.05; Tax 10% $27.00; Shipping $12.00; Total $345.45","Save and send opens the next number (QB-1042) with the toast \"QB-1042 saved and sent\", a blue Sent chip and \"Due 3 Nov 2026\""]},{"id":"FR-INV-02","weight":"Must","text":"A draft can be edited with live totals and then sent, which locks it.","accept":["A draft shows Edit, Send invoice, Duplicate, Print and Delete draft and no Record payment","Changing Shipping from 25.00 to 40.00 on a draft total of $1,410.80 updates the Total to $1,425.80 before saving","After Save and send the toast is \"QB-1040 saved and sent\", the chip is blue Sent, and Edit and Delete draft are gone while Record payment, Issue credit note and Void invoice appear"]},{"id":"FR-INV-03","weight":"Should","text":"An invoice cannot be saved without a customer and nothing the user typed is lost.","accept":["Save draft with no customer shows the red message \"Choose a customer\" above the Details card; the typed line description is kept"]},{"id":"FR-INV-04","weight":"Should","text":"The due date may not be earlier than the issue date; equal dates are accepted.","accept":["A due date of 18 Oct 2026 with issue date 19 Oct 2026 shows \"Due date cannot be before the issue date\" and saves nothing","A due date equal to the issue date saves with the toast \"QB-1042 saved as draft\"","For a EUR customer the editor shows euro symbols and the hint names EUR and the customer terms"]},{"id":"FR-INV-05","weight":"Should","text":"Duplicate copies lines and discounts into a new draft dated today.","accept":["The toast is \"Created draft QB-1042\"; the header shows a grey Draft chip, the customer, \"issued 19 Oct 2026\" and a DRAFT stamp","Totals equal the source invoice and a draft has no \"Balance due\" row","The due date is today plus the customer terms (18 Nov 2026 for Net 30)","The list then reads \"42 invoices shown\" with the new draft first and chip counts All 42, Draft 4"]},{"id":"FR-INV-06","weight":"Should","text":"A draft is deleted only after a confirm step and the counts update.","accept":["The dialog is \"Delete draft QB-1039?\" with \"The draft is removed permanently. This cannot be undone.\"","Escape closes the dialog and keeps the draft","Confirming shows the toast \"Draft deleted\", returns to the list and the counts read All 40 and Draft 2"]},{"id":"FR-INV-07","weight":"Must","text":"Invoice totals follow one formula: line amounts after line discount, then the invoice discount, then tax on the discounted amount rounded per tax-rate group, then shipping (untaxed).","accept":["Total = Sub-total - invoice discount + tax + shipping, with tax computed on the discounted amount of each rate group","A 10% invoice discount on lines totalling $1,399.80 gives Discount -$139.98, Tax 10% $125.98, Shipping $25.00 and Total $1,410.80","A sent invoice with a 5% line discount shows Sub-total $3,852.25; Discount (5%) -$192.61; Tax 10% $137.96; Total $3,797.60; Balance due $3,797.60","A line with a 5% line discount shows \"Line discount 5% (-$42.75)\""]}]},{"id":"PAY","title":"Payments and status","summary":"Recording payments and keeping the invoice status in step with the balance.","reqs":[{"id":"FR-PAY-01","weight":"Must","text":"An invoice is Paid only when its balance is zero; any invoice with money still owed keeps a non-Paid status.","accept":["An invoice of $1,800.00 with $600.00 paid is amber \"Partially paid\" with Balance due $1,200.00","Seeded chip counts stay Partially paid 1, Paid 19, Overdue 12","A part-payment never turns an invoice green Paid"]},{"id":"FR-PAY-02","weight":"Must","text":"Recording a part-payment lowers the balance and moves a Sent invoice to Partially paid.","accept":["The dialog \"Record payment\" says \"Balance due $2,000.00 on QB-1035\"","Paying 500.00 by Bank transfer closes the dialog with \"Payment of $500.00 recorded\"","The chip becomes amber \"Partially paid\", Balance due $1,500.00 and the Payments card lists +$500.00 dated 19 Oct 2026","The Partially paid chip then lists QB-1035 and QB-1033"]},{"id":"FR-PAY-03","weight":"Should","text":"A payment above the balance is refused; paying exactly the balance settles the invoice.","accept":["285.01 against a $285.00 balance shows \"Payment of $285.01 exceeds the balance due of $285.00\" inside the dialog","\"Use full balance\" fills 285.00 and the payment shows \"Payment of $285.00 recorded\"; the chip is green Paid with a PAID stamp and Balance due $0.00"]}]},{"id":"CRN","title":"Credit notes","summary":"Credit notes reduce what a customer owes and keep their own numbering.","reqs":[{"id":"FR-CRN-01","weight":"Must","text":"A credit note reduces the invoice balance and is listed with its own number and reason.","accept":["Balance due = Total - payments - credit notes; a $3,200.00 invoice with credit notes leaving $3,000.00 shows Balance due $3,000.00 in the preview and in the side card","The dialog reads \"Reduces the balance of QB-1037 ($285.00 due).\"; issuing $10.00 shows \"Credit note of $10.00 issued\"","Credit notes -$10.00 appears in the totals, Balance due is $275.00, and the side card lists \"CN-0005 · -$10.00\" with the reason \"Menu typo\""]},{"id":"FR-CRN-02","weight":"Should","text":"A credit note needs a reason.","accept":["Submitting with the reason empty shows \"A reason is required for a credit note\" and issues nothing"]}]},{"id":"VOID","title":"Voiding invoices","summary":"Owner-only cancellation of an invoice that should no longer be owed.","reqs":[{"id":"FR-VOID-01","weight":"Must","text":"Voiding needs a confirm step; cancel changes nothing; a voided invoice drops out of receivables.","accept":["The dialog \"Void QB-1035?\" explains the customer will no longer owe it and that voiding cannot be undone","Cancel closes the dialog and the chip stays Sent","Confirming shows \"QB-1035 voided\", a grey Void chip, a VOID stamp and Balance due $0.00","Outstanding receivables falls by $2,000.00 to $31,608.75, the status list shows Void 3 and Sent 3, and the Aging report drops the customer row and totals $31,608.75"]},{"id":"FR-VOID-02","weight":"Should","text":"Only the Owner can void; for a Bookkeeper the action is disabled with an explanation.","accept":["The Void invoice button is greyed out with the tooltip \"Only owners can void invoices\"","Clicking it opens no dialog and the status does not change"]},{"id":"FR-VOID-03","weight":"Must","text":"Void invoices owe nothing: they are excluded from Outstanding, Overdue, customer balances and the Aging report.","accept":["With the two seeded void invoices, Outstanding is $33,608.75 and Overdue $22,438.15","A customer whose only other invoice is void shows Outstanding $310.00 and Overdue $310.00 for the one invoice still owed","Aging 31–60 totals $7,245.00 and the grand total is $33,608.75"]}]},{"id":"CUS","title":"Customers","summary":"A directory of customers with a billing currency and payment terms.","reqs":[{"id":"FR-CUS-01","weight":"Should","text":"A customer can be added and then invoiced in its own currency and terms.","accept":["The Customers page reads \"13 customers\", sorted by name; the Add customer dialog has Name, Contact, City, Email, Currency and Payment terms","Adding shows the toast \"<Name> added\" and the subtitle \"14 customers\"; the new customer shows 0 invoices and an em dash under Outstanding","New invoice from that customer preselects it with the hint \"pays in GBP on Net 60 terms.\" and a due date 60 days after 19 Oct 2026 (18 Dec 2026)"]},{"id":"FR-CUS-02","weight":"Could","text":"Customer names are unique.","accept":["Adding an existing name shows a message of the form A customer named \"<Name>\" already exists inside the dialog, which stays open"]},{"id":"FR-CUS-03","weight":"Should","text":"A customer page summarises what they owe in their own currency and links to their invoices; customers with invoices cannot be deleted.","accept":["A USD customer with Net 30 shows Outstanding $3,435.00, Overdue $2,235.00 (\"Needs a reminder\") and Invoices 4 (\"0 in draft\")","Delete is disabled with the tooltip \"Customers with invoices cannot be deleted\"","Clicking an invoice row opens that invoice","An EUR customer shows Outstanding €7,000.00 and Overdue €3,400.00; customer balances do not use the aging buckets","A GBP customer shows Outstanding £1,425.00"]}]},{"id":"RPT","title":"Aging report","summary":"Unpaid balances grouped by days past due, converted to USD.","reqs":[{"id":"FR-RPT-01","weight":"Must","text":"The Aging report buckets every unpaid balance by days past due, converts to USD and adds up to the Overview figure.","accept":["The page says \"Amounts in USD (EUR 1.08, GBP 1.27)\" and \"Where the unpaid $33,608.75 sits\"","Buckets: Current $11,170.60; 1–30 days $9,675.00; 31–60 days $7,245.00; 61–90 days $3,948.15; 90+ days $1,570.00","The Total row repeats those buckets and totals $33,608.75","A EUR customer with €3,600.00 current and €3,400.00 in 31–60 shows $3,888.00 and $3,672.00, total $7,560.00 (EUR at 1.08)","Each customer row shows an em dash in buckets where it owes nothing"]},{"id":"FR-RPT-02","weight":"Must","text":"Bucket edges are inclusive: an invoice exactly 30 days past due is in 1–30; 31 days goes to 31–60.","accept":["An invoice due 19 Sep 2026 (30 days before the business date) shows $1,150.00 under 1–30 and an em dash under 31–60","The Total row shows 1–30 $9,675.00 and 31–60 $7,245.00"]}]},{"id":"KEY","title":"Keyboard shortcuts","summary":"Shortcuts for search and new invoices, and Escape to dismiss dialogs.","reqs":[{"id":"FR-KEY-01","weight":"Could","text":"\"/\" focuses the search box, \"n\" opens a new invoice, and Escape closes any dialog without acting.","accept":["On Invoices, \"/\" moves the cursor into \"Search number or customer\"","Outside any field, \"n\" opens the editor headed \"New invoice\"","Escape on the Issue credit note dialog closes it and issues nothing"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"At phone width (390px) the sidebar becomes a menu sheet and pages do not scroll sideways.","accept":["The top bar shows a menu button, the logo and a plus button","The menu lists Overview, Invoices (with count 41), Customers and Aging report","The invoice table shows Invoice, Due, Total and Status only, with the customer name under each number"]},{"id":"NFR-02","weight":"Must","text":"Money uses the customer currency symbol, thousands separators and exactly two decimals; Overview and Aging report figures are always in USD.","accept":["USD $, EUR €, GBP £; negative adjustments are prefixed with \"-\""]}],"glossary":[{"term":"Balance due","meaning":"Total minus payments minus credit notes; zero for a void invoice."},{"term":"Overdue","meaning":"Unpaid, past its due date on the business date of 19 Oct 2026."},{"term":"Net N","meaning":"Payment terms: the due date is N days after the issue date."}],"outOfScope":["Real payment processing or email delivery","Live exchange rates","Multi-user concurrency; each browser tab keeps its own copy of the books"]},{"ns":"review","title":"Diffline","version":"1.8","summary":"This product is a pull-request review tool for the halden/billing-service repository. Reviewers sign in, find a pull request in a filterable list, read its commits and unified or split diffs, discuss individual lines in threads, submit reviews and merge once every merge requirement is met.","problem":"Reviewers need to find the right change, read it line by line, track which conversations are still open and know exactly what blocks a merge, without merging something that has failing checks, missing approvals or unresolved discussion.","personas":[{"name":"Reviewer","need":"Read diffs, comment on lines, resolve threads and approve or request changes."},{"name":"Author","need":"See what blocks the pull request and merge it once everything is satisfied."},{"name":"Phone or tablet user","need":"Triage and review from a small screen without sideways scrolling."}],"goals":["The merge gate states every unmet requirement and never allows a merge while one is unmet.","Counts (tabs, files, additions, deletions, resolved conversations) always equal what is on screen.","Diffs read correctly: each deleted line sits beside the line that replaces it in split view."],"features":[{"id":"AUTH","title":"Sign-in and sign-out","summary":"Session access to the pull request list.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"A signed-in user lands on the \"Pull requests\" page for halden/billing-service on the Open tab.","accept":["The sign-in page is headed \"Sign in to\" followed by the product name, with Username, Password and a \"Sign in\" button","After sign-in the tab strip reads Open 8, Merged 3, Closed 2, All 13"]},{"id":"FR-AUTH-02","weight":"Should","text":"Failed sign-ins are rejected with a specific message and the form stays.","accept":["Empty username shows \"Enter your username\"","Wrong password shows \"Invalid credentials\" and no list is shown"]},{"id":"FR-AUTH-03","weight":"Could","text":"Sign out ends the session and returns to the sign-in page; Back does not reveal any pull request.","accept":["Sign out is in the left rail, or in the bottom bar on a phone"]}]},{"id":"LST","title":"Pull request list","summary":"Tabs, filters, search and sort over the repository’s pull requests.","reqs":[{"id":"FR-LST-01","weight":"Must","text":"The Open tab lists the 8 open pull requests newest first with correct title, labels, author, date, review and check chips, comment count and +/- totals.","accept":["Order: #421, #420, #418, #417, #415, #414, #412, #409","#421 is \"WIP: audit log CSV export\" with a Draft pill, label feature, \"#421 opened Oct 18, 2026 by Ines Carvalho\", \"1 pending\", 0 comments, \"+5 -0\"","#412 \"Add idempotency keys to POST /charges\": labels feature and security, \"4 passed\", 4 comments, \"+75 -1\"","#409 \"Fix rounding in partial and prorated refunds\": label bug, \"1 failing\", 3 comments, \"+12 -4\"","#415 \"Cache currency rates for 5 minutes\": \"3 passed\", 1 comment, \"+7 -0\""]},{"id":"FR-LST-02","weight":"Should","text":"Merged, Closed and All tabs list only pull requests in that state, each with a state pill.","accept":["Merged (3): #411, #405, #403","Closed (2): #407 and #398","All shows 13, first #421, last #398"]},{"id":"FR-LST-03","weight":"Should","text":"Label and author filters narrow the list and the heading count follows.","accept":["Label \"security\": 2 pull requests, #420 and #412","Author \"Bao Nguyen\": 2 pull requests, #415 and #409"]},{"id":"FR-LST-04","weight":"Should","text":"Search matches title text; an empty result shows a way back.","accept":["\"cache\" returns only #415","No match shows \"No pull requests match these filters\", \"0 pull requests\" and a \"Clear filters\" button that restores the 8 open pull requests"]},{"id":"FR-LST-05","weight":"Could","text":"\"Awaiting my review\" lists open pull requests where the signed-in user is a pending reviewer; the sort control can reverse the order.","accept":["\"Awaiting my review\": #414 and #412","\"Oldest\": #409, #412, #414, #415, #417, #418, #420, #421"]}]},{"id":"PRD","title":"Pull request page","summary":"Conversation, Commits and Files changed tabs with a sidebar of reviewers, checks and conversations.","reqs":[{"id":"FR-PRD-01","weight":"Should","text":"The page header and sidebar show the seeded author, branches, reviewers, checks and conversation summary.","accept":["#412: Open pill, \"Tomasz Kowalski wants to merge tomasz/idempotency-keys into main\", labels feature and security","Tabs: Conversation 4, Commits 4, Files changed 4 with \"+75 -1\"","Reviewers: Amira Hassan \"approved\"; Ines Carvalho and Bao Nguyen \"review requested\"","Checks: build, unit-tests and lint \"Passed · required\"; e2e-smoke \"Passed\"","Conversations: \"1 of 2 resolved\""]},{"id":"FR-PRD-02","weight":"Could","text":"The Commits tab lists the pull request’s commits oldest first with hash, message and author.","accept":["#412: a13f7c2 \"Add idempotency helpers\", 5be09d1 \"Wire idempotency into POST /charges\", f20c6ae \"Add idempotency_keys migration\", 62d8b07 \"Cover replay and conflict in tests\", all by Tomasz Kowalski"]},{"id":"FR-PRD-03","weight":"Could","text":"A merged pull request shows who merged it and how, and offers no merge or close action.","accept":["#411 shows \"Merged commit 4be71c9 into main by ahassan using squash and merge on Oct 12, 2026.\""]}]},{"id":"DIF","title":"Diff reading","summary":"Files changed tab with file tree, unified and split layouts and viewed marks.","reqs":[{"id":"FR-DIF-01","weight":"Must","text":"Files changed lists every changed file with its own +/- counts and starts with nothing viewed.","accept":["#412: \"0 of 4 files viewed\", Unified selected","idempotency.ts +37 -0; charges.ts +15 -1; 0042_idempotency_keys.sql +9 -0; charges.test.ts +14 -0","The first file section reads src/payments/idempotency.ts, \"added\", \"+37 -0\" with an unchecked Viewed box"]},{"id":"FR-DIF-02","weight":"Must","text":"Addition and deletion totals count every added and deleted line, in the list row, the tab and each file.","accept":["#412 shows \"+75 -1\" in the list and tab, and charges.ts \"+15 -1\"","src/payments/refunds.ts on #409 reads \"+3 -3\""]},{"id":"FR-DIF-03","weight":"Must","text":"In split view each deleted line sits on the same row as the added line that replaces it, and the chosen layout persists across reloads.","accept":["refunds.ts: left row 9 \"const unused = (daysInPeriod - daysUsed) / daysInPeriod;\" beside right row 9 \"const unusedDays = daysInPeriod - daysUsed;\"","Left row 10 \"return Math.round(total * unused);\" beside right row 10 \"return Math.floor((total * unusedDays) / daysInPeriod);\"","Unified view lists the deleted old lines 9 and 10, then the added new lines 9 and 10"]},{"id":"FR-DIF-04","weight":"Should","text":"Ticking Viewed folds the file, advances the \"N of M files viewed\" counter and is remembered after reload.","accept":["Folded file reads \"Marked as viewed — the diff is hidden.\" with a \"Show diff\" button; Viewed stays ticked when the diff is shown again","After viewing two files and reloading, \"2 of 4 files viewed\" is kept"]}]},{"id":"THR","title":"Inline conversations","summary":"Comment on a line, then resolve and unresolve the thread.","reqs":[{"id":"FR-THR-01","weight":"Must","text":"Clicking a line number opens a composer; adding a comment creates an unresolved conversation and updates the counts.","accept":["Composer reads \"Comment on line 2\" with Cancel and \"Add comment\"","An empty comment shows \"Comment cannot be empty\" and the composer stays","On success a toast \"Comment added\" appears; the Conversation tab reads 5 and the sidebar reads \"1 of 3 resolved\" listing idempotency.ts:2 as Unresolved"]},{"id":"FR-THR-02","weight":"Must","text":"Resolving and unresolving a thread updates the sidebar \"N of M resolved\" summary and the merge gate line in both directions.","accept":["Resolving charges.ts line 34 on #412 shows toast \"Conversation resolved\", summary \"2 of 2 resolved\" and gate line \"All conversations resolved\"","Unresolving idempotency.ts line 4 shows toast \"Conversation reopened\"; the resolved count drops by one (from \"1 of 2 resolved\" to \"0 of 2 resolved\" with gate \"2 unresolved conversations\")"]},{"id":"FR-THR-03","weight":"Could","text":"A resolved thread is collapsed to a one-line summary that expands on demand and offers \"Unresolve conversation\".","accept":["idempotency.ts line 4 shows a Resolved pill and \"Show resolved conversation (2 comments)\"","Expanded it shows Amira Hassan \"Is 24 hours enough for clients that retry from a queue?\" and Tomasz Kowalski \"Matches the payment provider window. I can make it configurable in a follow-up.\""]}]},{"id":"REV","title":"Reviews","summary":"Approve or request changes from the Review changes popover.","reqs":[{"id":"FR-REV-01","weight":"Must","text":"Approving adds the reviewer as \"approved\", increments the required-approvals count and refreshes the merge gate.","accept":["On #412 approving moves the Merge box from \"1 of 2 required approvals\" to \"2 of 2 required approvals\" and lists Ines Carvalho as \"approved\""]},{"id":"FR-REV-02","weight":"Should","text":"Requesting changes requires a summary; without one nothing changes.","accept":["\"Add a comment explaining the requested changes\" appears and the popover stays open","Reviewer states are unchanged afterwards"]},{"id":"FR-REV-03","weight":"Must","text":"The author of a pull request cannot approve it.","accept":["The popover shows \"You can't approve your own pull request\"","The reviewer list and the approval count do not change"]}]},{"id":"MRG","title":"Merge gate and merge","summary":"A pull request merges only when every requirement is met.","reqs":[{"id":"FR-MRG-01","weight":"Must","text":"The Merge box lists five requirements: ready for review, required checks passed, 2 required approvals, no changes requested, all conversations resolved. The button is \"Merge pull request\" only when all are met, otherwise a disabled \"Merge blocked\".","accept":["#412 initially: \"1 of 2 required approvals\" and \"1 unresolved conversation\" not met, others met, button disabled"]},{"id":"FR-MRG-02","weight":"Must","text":"A failing required check blocks the merge regardless of approvals.","accept":["With unit-tests \"Failed · required\", the requirement reads \"1 required check failing\" as not met and the button is a disabled \"Merge blocked\""]},{"id":"FR-MRG-03","weight":"Should","text":"Every unmet requirement is stated, including draft, pending check and review state.","accept":["#421: \"This pull request is still a draft\", \"1 required check pending\", \"0 of 2 required approvals\" not met; \"No changes requested\" and \"All conversations resolved\" met","#415: 3 checks passed (met); \"0 of 2 required approvals\", \"Changes requested by smoreau\" and \"1 unresolved conversation\" not met; Sophie Moreau is \"requested changes\""]},{"id":"FR-MRG-04","weight":"Must","text":"Merging asks for confirmation, then marks the pull request Merged with the chosen method and moves it between tab counts.","accept":["Methods include \"Create a merge commit\" (default) and \"Squash and merge\"; the squash title reads \"Add idempotency keys to POST /charges (#412)\" with counter \"44/72\"","Dialog \"Merge pull request #412?\" with Confirm merge; toast \"Pull request merged\"","Card reads \"Merged commit <hash> into main by you using squash and merge on Oct 19, 2026.\"","After merging #412 the tabs read Open 7, Merged 4, Closed 2, All 13"]},{"id":"FR-MRG-05","weight":"Must","text":"A pull request with no blockers merges with the default method and title.","accept":["#418: all five requirements met, \"2 of 2 required approvals\", method \"Create a merge commit\", Commit title \"Docs: describe the webhook retry policy (#418)\"","After merging, the card reads \"...using create a merge commit on Oct 19, 2026.\" and the merge button is gone"]},{"id":"FR-MRG-06","weight":"Should","text":"A pull request can be closed (after confirmation) and reopened; a draft must be marked ready before it can merge.","accept":["Dialog \"Close pull request #418?\" with Cancel; toast \"Pull request closed\"; pill Closed; merge box \"This pull request is closed. Reopen it to continue the review.\"; timeline \"closed this pull request\"","Reopen shows toast \"Pull request reopened\", pill Open and an enabled \"Merge pull request\"","\"Ready for review\" replaces the Draft pill with Open and adds timeline \"marked this pull request as ready for review\""]}]},{"id":"KEY","title":"Keyboard shortcuts","summary":"Review the files without a mouse.","reqs":[{"id":"FR-KEY-01","weight":"Should","text":"\"?\" opens a \"Keyboard shortcuts\" dialog listing j / k, v, s, /, ?, Esc and Ctrl+Enter; Esc closes it and \"/\" focuses search."},{"id":"FR-KEY-02","weight":"Should","text":"On Files changed, j and k move the current file, v marks it viewed and s toggles split view.","accept":["j makes charges.ts current; v gives \"1 of 4 files viewed\" and folds it; s switches to Split; k returns to idempotency.ts"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"On a 390 px phone the list stacks, navigation moves to a bottom bar (Pull requests, Requests, Shortcuts, Sign out) and no page scrolls sideways.","accept":["16 px side margins","On the pull request page the sidebar sections (Reviewers, Checks, Conversations, Labels) follow the conversation and the merge form is one full-width column"]},{"id":"NFR-02","weight":"Could","text":"On a 768 px tablet the file list sits above the diffs and wide code scrolls inside its file card, not the page."}],"glossary":[{"term":"Merge gate","meaning":"The set of five requirements that must all be met before a pull request can merge."},{"term":"Required check","meaning":"A CI check marked \"required\"; it must pass for the gate to open."},{"term":"Viewed","meaning":"A per-file mark that folds the diff and counts toward \"N of M files viewed\"."}],"outOfScope":["Real source control","Notifications and email","Branch protection settings"]},{"ns":"learn","title":"Coursefield","version":"2.0","summary":"Coursefield is an online learning platform. Learners browse a catalogue of twelve courses, enroll (free, paid or prerequisite-gated), work through modules of lessons and quizzes, track progress and earn certificates. Instructors get a gradebook for their courses.","problem":"Learners need a clear path through a course, in order, with honest progress and fair scoring; instructors need accurate cohort figures to spot learners at risk.","personas":[{"name":"Learner","need":"Find a course, enroll, watch lessons, take quizzes and earn a certificate only when the course is truly complete."},{"name":"Instructor","need":"See enrollment, progress and quiz results per course and remind learners who are falling behind."}],"goals":["Content order is enforced everywhere, including direct links.","Scores, progress and certificates are exact: no rounding up to completion and no credit for partly right answers where the rules say none.","Quiz limits (time and attempts) are enforced by the platform, not by learner goodwill."],"features":[{"id":"AUTH","title":"Sign-in and roles","summary":"Sign-in, deep-link return and role-based pages.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"Valid credentials open the Home page with a personal greeting for the learner.","accept":["URL /learn/","Heading \"Maya, you are one lesson from your Data Storytelling certificate.\" for the seeded learner"]},{"id":"FR-AUTH-02","weight":"Must","text":"A wrong password shows an error banner, keeps the user on the sign-in page and reveals no course content."},{"id":"FR-AUTH-03","weight":"Should","text":"An unauthenticated visit to a protected page redirects to sign-in with a next parameter and returns to that page after sign-in.","accept":["/learn/certificates redirects to sign-in with next=/certificates and opens Certificates after login"]},{"id":"FR-AUTH-04","weight":"Must","text":"The gradebook is available to instructors only; a learner is redirected Home and sees no Gradebook menu item; an instructor lands on the gradebook after sign-in."}]},{"id":"HOM","title":"Home","summary":"Learning dashboard and responsive layout.","reqs":[{"id":"FR-HOM-01","weight":"Must","text":"Home shows four tiles computed from the learner’s history and a \"Continue learning\" panel ordered by progress.","accept":["Seeded learner: In progress 3, Lessons completed 23, Certificates 1, Average quiz score 71%","Data Storytelling is first at 99% with next lesson \"Course wrap-up\""]},{"id":"FR-HOM-02","weight":"Could","text":"Home uses a tinted frame with sidebar, top search bar and a rounded white sheet; a 24 px regular-weight greeting above four equal tiles; panels on a grid with no clipped text or horizontal scroll."},{"id":"FR-HOM-03","weight":"Could","text":"At 390 px the sidebar becomes a drawer opened by a menu button (Home, Catalog, Certificates) that closes when the dimmed area is pressed; content keeps a 16 px gutter and tiles stack."}]},{"id":"CAT","title":"Catalogue","summary":"Browse, filter, search and sort courses.","reqs":[{"id":"FR-CAT-01","weight":"Must","text":"The unfiltered catalogue lists all twelve courses with price, level and an enrolled indicator.","accept":["The count reads 12 courses","Python for Analysts shows Free, Beginner and enrolled"]},{"id":"FR-CAT-02","weight":"Should","text":"Price, level, category and duration filters combine, narrow the list and are kept in the URL and after reload.","accept":["Price Free: 6 courses; plus Level Beginner: still 6, URL contains price=free and level=Beginner","Plus Category Data: Python for Analysts and SQL Essentials","Duration \"Under 2 hours\": 7 courses"]},{"id":"FR-CAT-03","weight":"Should","text":"Search matches course titles; an unmatched query shows an empty state with a way to clear filters.","accept":["Searching \"Negotiation\" leaves only \"Negotiation Skills\""]},{"id":"FR-CAT-04","weight":"Could","text":"Sorting by Shortest orders by duration ascending.","accept":["Security Awareness for Teams (1h 5m) first, then Writing for Clarity (1h 8m)"]},{"id":"FR-CAT-05","weight":"Could","text":"On a 390 px phone catalogue cards stack in one column with a 16 px gutter, text of at least 12 px and no horizontal scroll."}]},{"id":"CRS","title":"Course syllabus and locks","summary":"Modules, lessons, durations and module gating.","reqs":[{"id":"FR-CRS-01","weight":"Should","text":"The course page lists modules with their lessons, durations, completion and lock state.","accept":["Python for Analysts: Getting started (3 lessons), Core data structures (4 lessons and a quiz), Files and pandas (3 lessons and a quiz)","Getting started: \"Welcome and course tour\" 4m, \"Installing Python and Jupyter\" 9m, \"Your first notebook\" 12m, all complete","Files and pandas is locked: lock icons and its lessons are not links"]},{"id":"FR-CRS-02","weight":"Must","text":"A locked module cannot be opened by any route: its lessons are not links, and opening a lesson by direct URL shows a locked message with a link back to the course and no player. The module unlocks when its gating quiz is passed.","accept":["/learn/lessons/l-py-3-1 shows the locked message while Core data structures check is not passed","After passing that quiz Files and pandas is no longer locked"]}]},{"id":"ENR","title":"Enrollment","summary":"Free, paid and prerequisite-gated enrollment.","reqs":[{"id":"FR-ENR-01","weight":"Must","text":"Enrolling in a free course starts it immediately with no charge dialog.","accept":["Panel shows \"Free\" and \"Enroll for free\"; lesson 1 is a free preview","After enrolling the panel shows a ring at 0% and \"Continue: Know your reader\"; the catalogue marks the course enrolled"]},{"id":"FR-ENR-02","weight":"Should","text":"Enrolling in a paid course requires confirming the charge; cancelling (button or Esc) leaves the learner not enrolled.","accept":["\"Enroll for $59\" on Interface Design Systems opens a dialog stating the charge","Confirming enrolls with the ring at 0%"]},{"id":"FR-ENR-03","weight":"Should","text":"A course with a prerequisite cannot be enrolled until the prerequisite is complete.","accept":["Machine Learning Foundations shows a warning to complete Statistics in Practice first, with a link, and a disabled \"Enroll for $89\""]}]},{"id":"LSN","title":"Lesson player and notes","summary":"Video lesson with transcript, completion and notes.","reqs":[{"id":"FR-LSN-01","weight":"Must","text":"Playing a lesson to the end marks it complete, makes the next lesson available and advances course progress.","accept":["\"Spread and variance\" is a 16:00 lesson with player, transcript and notes tabs and an outline of six lessons","Speed 2x and the scrubber move the time counter and the highlighted transcript line","After completion the course page shows 2 of 6 lessons complete"]},{"id":"FR-LSN-02","weight":"Should","text":"Notes autosave and are shown again after a reload.","accept":["The existing note of \"Your first notebook\" reads \"Restart the kernel after installing packages.\"","After editing, a saved indicator appears and the new text persists after reload"]},{"id":"FR-LSN-03","weight":"Could","text":"A note is limited to 500 characters; 500 is accepted and the counter reads 500, the 501st is refused or explained."}]},{"id":"QUZ","title":"Quizzes","summary":"Single, multiple-choice, ordering and matching questions with timer, attempts and pass mark.","reqs":[{"id":"FR-QUZ-01","weight":"Should","text":"The quiz overview states question count, points, pass mark, attempts allowed and the history of previous attempts.","accept":["Core data structures check: 6 questions, 9 points, pass mark 70%, 3 attempts; one previous attempt 56% not passed; two attempts remain"]},{"id":"FR-QUZ-02","weight":"Must","text":"A fully correct attempt scores 100%, passes and unlocks the next module; the answer controls of all four question types work.","accept":["Result \"100%, 9 of 9 points, Passed\"","The question navigator marks answered questions"]},{"id":"FR-QUZ-03","weight":"Must","text":"A multiple-choice question earns its points only when the learner selects exactly the correct set of options.","accept":["Ticking only list and dict for the three mutable types: 0 / 2 points, marked Incorrect, correct answer lists list, dict and set; the quiz scores 78% (7 of 9 points)","Single-choice questions score normally"]},{"id":"FR-QUZ-04","weight":"Should","text":"A matching question awards points in proportion to correct pairs.","accept":["Two of four pairs right: 1 of 2 points, \"Partly correct\", quiz 89% (8 of 9)"]},{"id":"FR-QUZ-05","weight":"Could","text":"Ordering questions can be answered with up/down buttons as well as dragging, and the chosen order is kept when navigating away and back."},{"id":"FR-QUZ-06","weight":"Should","text":"Submitting with unanswered questions warns first, then fails the quiz when below the pass mark and consumes an attempt.","accept":["Only Q1 answered: result 11%, 1 of 9 points, Not passed; five questions \"Not answered\"","Retake button reads \"Try again (1 left)\""]},{"id":"FR-QUZ-07","weight":"Must","text":"The attempt limit is enforced: once all attempts are used no new attempt can start and no retake is offered.","accept":["Joins and grouping check allows 2 attempts, pass mark 75%, 60 seconds; after the second attempt (one already used) the start button is disabled or replaced by a \"no attempts left\" notice"]},{"id":"FR-QUZ-08","weight":"Could","text":"A timed quiz shows a countdown from the time limit that turns red under 10 seconds.","accept":["Timer starts at 1:00 and counts down every second"]},{"id":"FR-QUZ-09","weight":"Must","text":"When the countdown reaches zero the answers given so far are submitted automatically and the learner is taken to the result.","accept":["A banner says time ran out and answers were submitted automatically","With only question 1 answered correctly the score is 14% (1 of 7 points)"]},{"id":"FR-QUZ-10","weight":"Should","text":"The result review lists every question with a verdict, points, the learner answer, the correct answer and a one-line explanation.","accept":["The 56% attempt shows 5 of 9 points, pass mark 70%, Not passed and six review entries"]}]},{"id":"PRG","title":"Progress and certificates","summary":"Progress rings and certificate issue.","reqs":[{"id":"FR-PRG-01","weight":"Must","text":"Progress is shown as a percentage rounded down; 100% and \"Course complete\" appear only when every lesson is complete, and a certificate is issued only then.","accept":["Data Storytelling with 8 of 9 lessons (one minute of 240 left) reads 99%, \"8 of 9 lessons\" and has no certificate link","Completing \"Course wrap-up\" gives 100%, \"Course complete\", and \"View certificate CF-2026-52930\"; Certificates then lists two cards"]},{"id":"FR-PRG-02","weight":"Should","text":"Certificates lists earned certificates and each opens a certificate page with holder name, course, ID and issue date; unknown IDs show \"Certificate not found\" with a link to all certificates.","accept":["SQL Essentials: CF-2026-40817, issued 14 August 2026, holder Maya Okafor"]},{"id":"FR-PRG-03","weight":"Could","text":"The certificate page is a centred, framed, printable card that fits a 390 px viewport without horizontal scroll."}]},{"id":"GRD","title":"Instructor gradebook","summary":"Cohort figures and reminders.","reqs":[{"id":"FR-GRD-01","weight":"Should","text":"The gradebook shows enrollment and status counts per course that equal the rows listed, and status filters list exactly the matching learners.","accept":["Python for Analysts: Enrolled 14, At risk 4; chips On track 6, At risk 4, Completed 4","At risk: Elif Demir, Grace Nakamura, Jonas Weber, Liam Doherty"]},{"id":"FR-GRD-02","weight":"Could","text":"Column headers sort the table with a sort arrow and the course select swaps the data.","accept":["Progress descending then ascending; Liam Doherty (22%) first when ascending","SQL Essentials: one quiz column, fourteen rows, five Completed"]},{"id":"FR-GRD-03","weight":"Could","text":"Send reminder is offered only for at-risk learners, needs confirmation naming the learner and idle days, then shows a toast and a disabled \"Reminded\" button.","accept":["Elif Demir: idle 22 days","On-track learners have no reminder button"]}]}],"nfr":[],"glossary":[{"term":"Pass mark","meaning":"Minimum quiz percentage needed to pass and to unlock the gated module."},{"term":"At risk","meaning":"A learner who has been idle and is behind on progress; eligible for a reminder."}],"outOfScope":["Real video playback","Payments","Email delivery of reminders"]},{"ns":"forms","title":"Fieldwise","version":"3.0","summary":"Fieldwise is a form builder. Owners create forms from templates or a blank canvas, arrange and configure questions with drag-and-drop, add conditional logic and multiple pages, preview, publish to a public link, review submissions (filter, export to CSV) and read per-form analytics (completion rate, drop-off per page). There is no sign-in; each browser tab session has its own workspace of six sample forms and 62 responses.","problem":"Teams need to collect structured responses without code, and must trust that what the owner designs (order, rules, required fields, limits) is exactly what respondents experience and what the owner later exports.","personas":[{"name":"Form owner","need":"Build, publish and close forms, then read and export responses and see where respondents drop off."},{"name":"Respondent","need":"Fill a multi-page form on any device, with clear validation and confirmation."}],"goals":["A saved form is the form respondents see, in the order and with the rules the owner set.","Validation is identical in the browser and on the server, on every page.","Exports and counts always reflect exactly what the owner is looking at."],"features":[{"id":"LST","title":"Forms list","summary":"Home screen with all forms, search and status filter.","reqs":[{"id":"FR-LST-01","weight":"Must","text":"The list shows every form with status, description, response count, question/page counts and last response date.","accept":["Heading \"Forms\" and \"6 forms in this workspace\"","Order: Newsletter preferences, IT access request, Customer feedback survey, Harbour Summit registration, Volunteer sign-up, Q3 pulse check; statuses Draft, Draft, Published, Published, Published, Closed","Customer feedback survey: 24 responses, \"10 questions · 3 pages\", last response 16 Oct 2026","Newsletter preferences: 0 responses, \"3 questions\", \"None yet\", \"No description\""]},{"id":"FR-LST-02","weight":"Should","text":"Status buttons and the search box narrow the list and update the count line; an unmatched search shows an empty state with Clear filters.","accept":["Published: \"3 forms match\"; Draft: \"2 forms match\"; Closed: \"1 form match\"","Searching \"summit\" leaves Harbour Summit registration","Empty state \"No forms match\" with \"Try a different search or status filter.\"; Clear filters restores all six"]}]},{"id":"NEW","title":"Create a form","summary":"New form dialog with templates.","reqs":[{"id":"FR-NEW-01","weight":"Must","text":"A form is created from a template (Blank form, Contact us, Event registration, Quick survey) and opens in the builder as a Draft.","accept":["Contact us contains 3 questions: Short text \"Your name\", Email \"Email address\", Long text \"How can we help?\", each Required","The new form opens at /forms/f-107 with a Draft pill and appears first in the list (\"7 forms in this workspace\")"]},{"id":"FR-NEW-02","weight":"Should","text":"The form name must be 2 to 80 characters and unique; Escape closes the dialog.","accept":["Empty: \"Enter a name for the form\"","Duplicate: `A form named \"Customer feedback survey\" already exists`","One character: \"Form name must be 2 to 80 characters\""]}]},{"id":"BLD","title":"Builder","summary":"Canvas, palette of 11 element types, properties and options editor.","reqs":[{"id":"FR-BLD-01","weight":"Must","text":"A blank form can be assembled from the palette, configured and saved; everything survives a reload.","accept":["A blank form shows \"Your form is empty\" and a palette of 11 types","Adding Short text, Dropdown (default options Option 1 and Option 2) and Rating (5 stars) creates nodes in order","Editing marks \"Unsaved changes\"; Save shows toast \"Form saved\" and \"All changes saved\""]},{"id":"FR-BLD-02","weight":"Must","text":"Question order can be changed with Move up/down and with the keyboard on the grip (ArrowUp/ArrowDown) and the saved order is exactly the order shown, also after reload.","accept":["Newsletter preferences Email address, Topics, Frequency; Move up twice gives Frequency, Email address, Topics","Move up is disabled on the first question","Save then reload keeps that order"]},{"id":"FR-BLD-03","weight":"Should","text":"Dragging a palette element or a question grip with the pointer shows a drop line and inserts or moves the question at the drop position.","accept":["Dropping Date above Topics inserts a selected \"Date question\" between Email address and Topics","Dropping at the bottom appends"]},{"id":"FR-BLD-04","weight":"Should","text":"The properties panel edits label, required flag and placeholder, updating the canvas immediately; a blank label is flagged.","accept":["Placeholder text appears in the canvas input","Switching Required off removes the Required badge","Empty label: \"Add a label before publishing\"","Saving a changed label shows \"Form saved\" and the new node title"]},{"id":"FR-BLD-05","weight":"Should","text":"The options editor adds, moves and removes options and enforces unique values and a minimum of two options for publishing.","accept":["Add option appends \"Option 3\"; Move option 3 up reorders","Duplicate value (compared case-insensitively): \"Options must be unique\"","With one option left: \"Add at least two options to publish\" and its remove button is disabled"]}]},{"id":"LOG","title":"Conditional logic","summary":"Show-when rules between questions.","reqs":[{"id":"FR-LOG-01","weight":"Should","text":"The Logic tab lists the form’s rules in plain language with a count badge, and conditional questions carry a \"Conditional\" badge in the builder.","accept":["Customer feedback survey: badge 1; rule \"Show \"What went wrong?\" when \"Overall rating\" is less than 3 stars\"","A form without rules shows \"No rules yet\" and an \"Add rule\" button"]},{"id":"FR-LOG-02","weight":"Must","text":"A rule can be created from target, source, condition and value and takes effect in Preview, showing and hiding the target as the answer changes.","accept":["Adding \"Show Frequency when Topics contains Events\": Frequency is hidden until \"Events\" is ticked and hidden again when unticked","The Value control follows the source question’s options","Delete rule returns to \"No rules yet\""]},{"id":"FR-LOG-03","weight":"Must","text":"The comparison \"is less than\" is strict: a rating below 3 reveals the follow-up question and a rating of exactly 3 does not. A revealed question is required and counts toward validation.","accept":["Rating 2: \"What went wrong?\" appears with a required marker and a \"0 / 300\" counter","Rating 3, 4 or 5: it stays hidden","Leaving it empty: \"This question is required\""]},{"id":"FR-LOG-04","weight":"Should","text":"Registration form rules show Workshop track only for the \"Workshop pass\" ticket and \"Please list your allergies\" only when \"Nut allergy\" is ticked; both are required while visible.","accept":["Initially neither is shown","Choosing VIP hides Workshop track again; unticking Nut allergy hides the allergies question"]}]},{"id":"FIL","title":"Public form and validation","summary":"Multi-page respondent experience with a progress bar.","reqs":[{"id":"FR-FIL-01","weight":"Must","text":"A multi-page form shows progress (\"Page n of N\" with percentage), moves with Next/Back keeping answers, and a complete response ends on a thank-you screen with a submission ID.","accept":["Customer feedback survey starts at \"Page 1 of 3\" 33%, then \"Page 2 of 3 · Your experience\" 67%, \"Page 3 of 3 · Final thoughts\" 100% with the button \"Submit\"","Result: \"Response submitted\", \"Thank you for your feedback, it helps us improve.\" and a Submission ID such as \"S-1063\"","Back returns to earlier pages with answers preserved"]},{"id":"FR-FIL-02","weight":"Must","text":"Required and format rules are checked on every page before Next and before Submit, in the browser and on the server; the first invalid question gets focus.","accept":["Empty page 1: banner \"Fix 3 questions to continue\" and \"This question is required\" under Full name, Email address and Overall rating; still on page 1","Invalid email: \"Enter a valid email address\"","An empty required question on page 2 shows \"This question is required\" and the respondent stays on page 2","The final submit rejects a response with an unanswered required question on any page"]},{"id":"FR-FIL-03","weight":"Must","text":"Number questions enforce their range inclusively and reject non-numbers; Age on the registration form accepts 18 through 99.","accept":["17: \"Must be at least 18\" and no submission is created","100: \"Must be at most 99\"","\"abc\": \"Enter a number\"","18 and 99 are accepted"]},{"id":"FR-FIL-04","weight":"Must","text":"A valid single-page submission is accepted and appears in the owner’s Submissions with device and answers.","accept":["Registration: \"Response submitted\", \"You are registered. A confirmation is on its way.\"","Submissions then shows \"19 responses\" with the new response first, device Desktop, and its detail dialog lists every answer"]},{"id":"FR-FIL-05","weight":"Should","text":"A closed form shows a notice instead of questions.","accept":["\"This form is closed and no longer accepts responses\" and \"If you think this is a mistake, contact the form owner.\"; no inputs or submit button"]},{"id":"FR-FIL-06","weight":"Could","text":"Unknown links and unpublished (draft) forms show \"Form not found\".","accept":["\"This form is not available. The link may be wrong, or the form has not been published.\""]}]},{"id":"PUB","title":"Publish and close","summary":"Making a form live and taking it offline.","reqs":[{"id":"FR-PUB-01","weight":"Must","text":"Publishing a draft makes it live at a public URL and shows the link.","accept":["Dialog \"Your form is live\" with the link containing /forms/f/newsletter-preferences, \"Copy link\" (becomes \"Copied\"), \"Done\", \"Open public form\"","The pill becomes Published and a \"Share\" button replaces \"Publish\"","The public page shows the form’s questions"]},{"id":"FR-PUB-02","weight":"Should","text":"A form with no questions cannot be published and the reason is shown.","accept":["Toast \"Cannot publish: Add at least one question before publishing\"; banner \"Not ready to publish\"; pill stays Draft","After adding one question publishing succeeds"]},{"id":"FR-PUB-03","weight":"Should","text":"Closing responses requires confirmation and is reflected on the public link; a closed form can be reopened.","accept":["\"Close this form?\" with \"Close form\" and \"Cancel\"; Cancel changes nothing","Toast \"Form closed\", pill Closed, \"Reopen\" shown, and the public link shows the closed notice","Reopen shows \"Your form is live\" and the pill Published"]}]},{"id":"SUB","title":"Submissions","summary":"Response table, filters, detail, export and delete.","reqs":[{"id":"FR-SUB-01","weight":"Should","text":"The Submissions tab lists responses with ID, submitted time, device and answers, 10 per page, newest first.","accept":["Customer feedback survey: tab badge 24, \"24 responses\", headers ID, Submitted, Device, Full name, Email address, Date of stay, Time","First row S-1024, 16 Oct 2026, 08:41, Desktop, Sofia Marchetti","Pager \"Page 1 of 3\" with Previous disabled; page 3 ends with S-1004 … S-1001 and Next disabled"]},{"id":"FR-SUB-02","weight":"Should","text":"Device, date range and search filters combine, the count line states how many responses match, and no match shows an empty state.","accept":["Mobile: \"11 responses match the filters\"","From 2026-10-10: 4 responses (S-1022, S-1021, S-1019, S-1018); From 10-10 to 10-12: 3 responses","Search \"Mei\": 2 responses (S-1023, S-1010)","No match: \"No responses match\" with Clear filters; \"Oldest first\" starts at S-1001"]},{"id":"FR-SUB-03","weight":"Could","text":"Opening a response shows a dialog with every question and its answer plus submit metadata.","accept":["S-1023: \"Submitted 15 Oct 2026, 13:39 UTC from Desktop in 2:31\"; Overall rating 2; Which services did you use? \"Spa; Restaurant\"; unanswered optional question \"No answer\""]},{"id":"FR-SUB-04","weight":"Must","text":"Export CSV contains exactly the responses currently matching the filters and the toast reports that number.","accept":["No filter: file f-101-submissions.csv, toast \"Exported 24 responses to CSV\"","Device Mobile: toast \"Exported 11 responses to CSV\", header row plus 11 rows all Mobile","Device Tablet: \"Exported 4 responses to CSV\""]},{"id":"FR-SUB-05","weight":"Could","text":"Deleting a response needs confirmation, then updates counts everywhere.","accept":["Dialog \"Delete this response?\" — \"Response S-1024 will be permanently removed and excluded from exports and analytics.\"","Cancel keeps it; confirming shows \"Deleted response S-1024\", \"23 responses\" and tab badge 23"]}]},{"id":"ANA","title":"Analytics","summary":"Completion rate and per-page drop-off.","reqs":[{"id":"FR-ANA-01","weight":"Must","text":"Analytics shows Views, Started, Completed, Completion rate (completed ÷ started) and Average time, with a drop-off row per page whose figures agree with the events. Data may take about 2 seconds to load, showing skeleton rows meanwhile.","accept":["Customer feedback survey: Views 138; Started 64; Completed 24; Completion rate 37.5%; Average time 3:28","Drop-off: Page 1 reached 64, dropped 17, 26.6%; Your experience 47, 14, 29.8%; Final thoughts 33, 9, 27.3%","Devices: Desktop 9, Mobile 11, Tablet 4"]},{"id":"FR-ANA-02","weight":"Could","text":"A one-page form has a single drop-off row and a form with no traffic shows an empty state.","accept":["Harbour Summit registration: Views 96; Started 41; Completed 18; 43.9%; Average time 4:05; one row \"Page 1\" reached 41, dropped 23, 56.1%","Newsletter preferences: \"No activity yet\" with \"Analytics fill in once people open the public link. Publish the form and share it to get started.\""]}]},{"id":"PRV","title":"Preview","summary":"Try a draft exactly as a respondent would.","reqs":[{"id":"FR-PRV-01","weight":"Should","text":"Preview runs the real fill flow with the same validation and logic, includes unsaved changes, and never stores a submission.","accept":["Banner \"Preview mode. Answers are checked like a real response but nothing is saved. This preview includes your unsaved changes.\"","IT access request: \"Page 1 of 2\" 50%; empty Next gives \"Fix 3 questions to continue\"; choosing Access level \"Admin\" reveals a required \"Approver name\"","Result \"Preview complete\" — \"Preview only. Nothing was sent and no submission was stored.\"; Submissions still shows \"0 responses\""]}]},{"id":"API","title":"API","summary":"Form lookup and public submission endpoints.","reqs":[{"id":"FR-API-01","weight":"Should","text":"The API validates independently of the UI and answers with the proper status code and message.","accept":["List forms: 200 with total 6","Unknown form: 404 \"Form f-999 not found\"","Submitting an empty answer set to Customer feedback survey: 422 with \"This question is required\" for q1, q2, q4, q7 and q10","Submitting to a closed form: 410 \"This form is closed and no longer accepts responses\"","Unknown public slug: 404 \"This form is not available\""]}]}],"nfr":[{"id":"NFR-01","weight":"Could","text":"At 390 px nothing scrolls sideways: list rows and submissions stack, the palette becomes a row of chips, question properties open in a bottom sheet with Done, and the public form is a single column with 16 px side padding."},{"id":"NFR-02","weight":"Could","text":"At 768 px the builder shows palette above canvas, analytics tiles reflow to a three-column grid, and submission filters wrap without clipping the ID column."},{"id":"NFR-03","weight":"Could","text":"The dark colour scheme keeps all text and controls legible: light text, lighter blue accent, readable status pills and progress bar."}],"glossary":[{"term":"Completion rate","meaning":"Completed responses divided by people who started the form."},{"term":"Drop-off","meaning":"People who reached a page but did not continue to the next."},{"term":"Workspace","meaning":"The set of forms and responses private to one browser tab session."}],"outOfScope":["Real file storage for upload questions (only name and size are kept)","Email notifications","User accounts and sharing between workspaces"]},{"ns":"chess","title":"Gambit Club","version":"1.8","summary":"Gambit Club is a small online chess club. Players face deterministic bots (Levels 1 to 3) or a friend on one screen, move by click or typed notation, import and export FEN and PGN, solve mate puzzles, replay finished games and track an Elo rating. Guests can play casual games and solve puzzles; rated games, History and Profile need an account.","problem":"Players need a chess board that applies the rules of the game exactly and predictably, with ratings that move in the right direction, so that results and history can be trusted.","personas":[{"name":"Club member","need":"Play rated games against bots and watch the rating and record follow every result."},{"name":"Casual guest","need":"Start a game or a puzzle immediately without an account."},{"name":"Learner","need":"See legal moves, check warnings and clear notation, and replay finished games move by move."}],"goals":["Every rule of chess (castling, en passant, promotion, stalemate, checkmate) is enforced exactly.","Bots always answer the same moves the same way, so games are repeatable.","A rated result always moves the rating in the right direction and is recorded in History and Profile."],"features":[{"id":"AUTH","title":"Sign-in and access","summary":"Account sign-in, guest access and the pages that require an account.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"A member signs in with username and password and lands on the Play screen showing their name and rating.","accept":["The top bar shows the display name, \"Rating <n>\" and a \"Sign out\" button","The \"Rated game\" option becomes enabled"]},{"id":"FR-AUTH-02","weight":"Should","text":"Sign-in reports missing and wrong input separately and never signs the user in on error.","accept":["No username: \"Enter your username\" with focus in the username field","No password: \"Enter your password\"","Wrong credentials: \"Wrong username or password\" and the login screen stays open","\"Continue as guest\" opens Play with the top bar showing \"Guest\""]},{"id":"FR-AUTH-03","weight":"Could","text":"History and Profile are available to signed-in members only; Puzzles stay open to guests.","accept":["Guest History: \"Sign in to see your games\"","Guest Profile: \"Sign in to see your profile\""]}]},{"id":"LOB","title":"Game lobby","summary":"Choosing opponent, level, colour and clock before a game.","reqs":[{"id":"FR-LOB-01","weight":"Must","text":"The lobby offers every game option with fixed defaults and navigation to the four areas of the club.","accept":["Heading \"New game\"; Opponent: Bot / Hot-seat; Bot level: Level 1, 2, 3; Your colour: White / Black; Time control: 5+0, 3+2, Untimed","Defaults: Bot, Level 1, White, Untimed","Sidebar: Play, Puzzles, History, Profile"]},{"id":"FR-LOB-02","weight":"Must","text":"Rated play is locked for guests and available to signed-in members against bots.","accept":["Guest: \"Rated game\" is disabled with the note \"Sign in to play rated games.\" and a Sign in link"]},{"id":"FR-LOB-03","weight":"Must","text":"Starting a game opens the board with a heading that names the game and the side to move.","accept":["Bot game: \"Game vs Bot L<level>\", status \"Your move\", \"No moves yet\"","Hot-seat game: \"Hot-seat game\", \"White to move\"","Starting a new game while one is in progress asks for confirmation (\"Leave game\")"]}]},{"id":"BRD","title":"Board and move entry","summary":"Click and typed moves, visual aids and notation.","reqs":[{"id":"FR-BRD-01","weight":"Must","text":"Clicking one of the player’s own pieces selects it and marks only its legal destination squares; clicking it again clears the selection.","accept":["The knight on g1 at the start offers f3 and h3 only","Clicking an opponent piece shows no dots"]},{"id":"FR-BRD-02","weight":"Must","text":"Moves may be typed in algebraic notation or coordinate form; an unreadable or illegal move is rejected without changing the position.","accept":["\"e4\", \"Bc4\", \"O-O\" and \"e2e4\" are accepted when legal","Otherwise the inline error is: Illegal or unreadable move \"<text>\""]},{"id":"FR-BRD-03","weight":"Must","text":"The move list is written in standard algebraic notation, numbered per full move.","accept":["Captures use x (Nxe4, exd5), check +, mate #, castling O-O, promotion a8=N","Black’s move after a break reads \"2. ... Qh4#\""]},{"id":"FR-BRD-04","weight":"Should","text":"The king in check glows on the board and in the status line, and the glow clears when the check is resolved.","accept":["Status reads \"Black to move, in check\"","After the check is parried the status reads \"White to move\" with no glow"]},{"id":"FR-BRD-05","weight":"Could","text":"The last move is tinted gold on both its squares and only those squares."},{"id":"FR-BRD-06","weight":"Could","text":"Each player strip shows captured pieces and a \"+N\" material difference for the side ahead.","accept":["Equal material shows no \"+\" on either side","After 1.e4 d5 2.exd5 White’s strip shows one black pawn and \"+1\""]},{"id":"FR-BRD-07","weight":"Could","text":"The board can be flipped by the \"Flip board\" button or the F key; a game played as Black starts flipped.","accept":["Unflipped: ranks 1 to 8 in the left column (8 on top), files a to h along the bottom","Flipped: Black at the bottom, rank 1 on top, files run h to a"]}]},{"id":"RUL","title":"Rules of chess","summary":"Exact enforcement of special moves and game endings.","reqs":[{"id":"FR-RUL-01","weight":"Must","text":"A king may not castle out of, through or into an attacked square.","accept":["With a bishop attacking f1, the king on e1 has no destination dots and \"O-O\" is rejected as illegal","Once the diagonal is blocked the position is playable again","Castling with no attacked square is legal: O-O puts the king on g1 and the rook on f1"]},{"id":"FR-RUL-02","weight":"Must","text":"En passant is legal only on the move immediately after the double pawn step.","accept":["1.e4 a6 2.e5 d5 3.exd6 captures the d5 pawn","If any other move is played first, exd6 is rejected as illegal"]},{"id":"FR-RUL-03","weight":"Must","text":"A pawn reaching the last rank opens a \"Promote pawn to\" dialog offering Queen, Rook, Bishop and Knight, and the chosen piece is placed.","accept":["Escape closes the dialog and leaves the pawn unmoved","Choosing Knight on a8 gives a white knight and the notation a8=N"]},{"id":"FR-RUL-04","weight":"Must","text":"A side with no legal move and not in check has drawn by stalemate, never lost.","accept":["Status \"Draw by stalemate\"; the move box is replaced by \"New game\" and \"Export PGN\""]},{"id":"FR-RUL-05","weight":"Must","text":"A side in check with no legal move is checkmated and the opponent wins.","accept":["Status \"<Side> wins by checkmate\"","Casual games show \"Casual game, no rating change\""]}]},{"id":"BOT","title":"Bot opponents","summary":"Deterministic replies for Levels 1 to 3.","reqs":[{"id":"FR-BOT-01","weight":"Must","text":"The Level 1 bot answers 1.e4 with a5 and 2.Bc4 with a4, so a fixed opening is repeatable.","accept":["The bot reply appears automatically and the status returns to \"Your move\""]},{"id":"FR-BOT-02","weight":"Should","text":"Bot replies are fixed for a given sequence of moves, and Level 3 differs from Levels 1 and 2 on the third move.","accept":["After e4, Nf3, d4 Levels 1 and 2 reply a5, a4, a3","Level 3 replies a5, a4, b6","When the user plays Black every bot opens with Na3"]},{"id":"FR-BOT-03","weight":"Could","text":"Undo is available only in casual games against a bot and takes back the last move together with the bot reply.","accept":["Undo is disabled before any move and in hot-seat games","After e4 a5, Undo restores the start position with \"No moves yet\""]}]},{"id":"END","title":"Resign, draw and clocks","summary":"Ending a game by resignation or agreement, and time controls.","reqs":[{"id":"FR-END-01","weight":"Should","text":"Resigning asks for confirmation first; Cancel keeps the game going.","accept":["Dialog \"Resign this game?\" with Cancel and Resign, focus on Cancel","Confirming gives \"<Opponent side> wins by resignation\""]},{"id":"FR-END-02","weight":"Should","text":"Draw offers are confirmed first; the bot declines early offers and a hot-seat opponent may accept or decline.","accept":["Confirm dialog \"Offer a draw?\"","Bot: \"The bot declined: too early for a draw\"","Hot-seat: the callout \"<Side> offers a draw\" with Accept and Decline; Accept gives \"Draw by agreement\", Decline continues the game"]},{"id":"FR-END-03","weight":"Could","text":"Each side has a clock matching the chosen time control, and the side to move counts down.","accept":["5+0 starts both clocks at 5:00; 3+2 at 3:00","Untimed shows no clocks"]}]},{"id":"RAT","title":"Ratings","summary":"Elo changes for rated games against bots.","reqs":[{"id":"FR-RAT-01","weight":"Must","text":"A rated win raises the rating and a rated loss lowers it; the result card shows the new rating and the signed change.","accept":["Rated win over Level 1 from 1340 shows \"Rating 1341 (+1)\"","Resigning a rated Level 2 game from 1340 shows \"Rating 1314 (−26)\""]},{"id":"FR-RAT-02","weight":"Must","text":"The Profile reflects every rated result immediately; casual games never change the rating.","accept":["After the win above: Rating 1341, Rated games 8, Record 3W 2D 3L","After the resignation above: Rating 1314, Rated games 8, Record 2W 2D 4L, Last change −26"]}]},{"id":"IMP","title":"Import and export","summary":"FEN and PGN in and out.","reqs":[{"id":"FR-IMP-01","weight":"Should","text":"Export PGN shows the moves played in standard notation, and Copy FEN confirms with a toast.","accept":["After e4 e5 Nf3 the text contains 1. e4 e5 2. Nf3","The exported text lists all moves in the order played"]},{"id":"FR-IMP-02","weight":"Should","text":"Import accepts a FEN or PGN and starts a hot-seat game at that position; empty or invalid input keeps the dialog open with an error.","accept":["Empty: \"Paste a FEN or PGN first\"","Invalid FEN: an error under the text box","Valid input: heading \"Imported position\" and play continues from it, e.g. a 5-ply PGN ends with Bb5 and \"Black to move\""]}]},{"id":"PUZ","title":"Puzzles","summary":"Eight mate puzzles with forced replies.","reqs":[{"id":"FR-PUZ-01","weight":"Should","text":"Eight puzzles are listed and solving one marks it Solved.","accept":["The list runs from \"Back-rank finish\" to \"King steps up\", none Solved initially","A wrong move shows \"Try again\" and resets; the right move shows \"Correct\" and offers Restart and Next puzzle"]},{"id":"FR-PUZ-02","weight":"Could","text":"Multi-move puzzles play the forced reply and expect the follow-up mate.","accept":["\"Queen sacrifice on b8\": Qb8+ shows \"Correct. Black replied Rxb8, now find the mate\"; Rxb8 completes it","\"Next puzzle\" opens \"King steps up\""]}]},{"id":"HIS","title":"History, replay and profile","summary":"Finished games, replay and rating chart.","reqs":[{"id":"FR-HIS-01","weight":"Must","text":"History lists the member’s finished games newest first and can be filtered by outcome.","accept":["Seeded member: 12 rows; Wins 3, Draws 4, Losses 5","The newest row is a loss to Bot L3 by resignation on Sep 24, 2026; the oldest is a win over Bot L1 by checkmate on Sep 2, 2026","A member with no games sees an empty state"]},{"id":"FR-HIS-02","weight":"Could","text":"A finished game can be replayed move by move with buttons and keyboard.","accept":["Replay opens at the start position; Left arrow steps back, Home returns to the start","An unknown game id shows \"Game not found\""]},{"id":"FR-HIS-03","weight":"Could","text":"Profile shows rating, rated games, record and last change, with a rating line chart.","accept":["Seeded member: Rating 1340, Rated games 7, Record 2W 2D 3L, Last change −14; line from 1400 to 1340 over 8 points","A member with no games sees an empty chart state"]}]}],"nfr":[{"id":"NFR-01","weight":"Should","text":"The app fits a 390 px phone with no horizontal scroll, a bottom tab bar replacing the sidebar and the board filling the width with 16 px margins.","accept":["Dialogs fit the screen; puzzle cards stack in one column"]}],"glossary":[{"term":"Hot-seat","meaning":"Two players taking turns on one screen."},{"term":"Rated game","meaning":"A game against a bot whose result changes the member’s Elo rating."},{"term":"FEN / PGN","meaning":"Standard text formats for a position and for a list of moves."}],"outOfScope":["Waiting for a clock to run out","Drag gestures","Real networks or opponents"]},{"ns":"stocks","title":"Northbeam Markets","version":"1.8","summary":"Northbeam Markets is a brokerage over a deterministic simulated market of 34 fictional tickers and the NB 100 index. Investors keep watchlists, study candlestick symbol pages, place market, limit, stop and stop-limit orders behind a trade-PIN step-up, follow open and partially filled orders, review the portfolio and FIFO realised profit, and set price alerts. Market time only moves with the Market time control.","problem":"Investors need to trust every quote, order, fill and profit figure: orders must never fill at a worse price than requested, cash reserved by working orders must not be spent twice, and realised profit must follow the oldest purchase lots.","personas":[{"name":"Active investor","need":"Place orders quickly, see exactly what they will cost and follow fills as the market moves."},{"name":"Portfolio watcher","need":"Track watchlists, positions, allocation and alerts without placing trades."},{"name":"Tax-minded investor","need":"Read realised profit and loss by lot, computed first-in first-out."}],"goals":["Orders fill only at prices their type and limit allow.","Buying power always equals cash minus cash reserved by working orders.","Realised profit and loss is computed per FIFO lot and totals reconcile.","Trading is protected by a trade PIN that locks after repeated failures."],"features":[{"id":"AUTH","title":"Sign-in and trade PIN","summary":"Account sign-in and the PIN step-up required before an order is placed.","reqs":[{"id":"FR-AUTH-01","weight":"Must","text":"A valid sign-in opens Watchlists with the navigation rail and the market clock.","accept":["Heading \"Watchlists\"; navigation lists Watchlists, Portfolio, Orders (with the count of open orders, 4 for the seeded account), Activity, Alerts","Topbar shows \"Mon 19 Oct 2026 · 10:00\", \"Open\" and \"Buying power\" $4,526.70"]},{"id":"FR-AUTH-02","weight":"Should","text":"Sign-in reports missing and wrong credentials separately and does not sign in.","accept":["Empty fields: \"Enter your username and password\"","Wrong credentials: \"Invalid username or password\" and the sign-in page stays"]},{"id":"FR-AUTH-03","weight":"Must","text":"The first order of a session requires the 6-digit trade PIN before it is placed.","accept":["\"Place order\" opens \"Confirm trade PIN\"","Empty PIN: \"Enter your 6-digit trade PIN\"","A correct PIN closes the dialog and the toast starts with \"Order ORD-<n> placed\""]},{"id":"FR-AUTH-04","weight":"Must","text":"Three wrong PINs lock trading for the session.","accept":["First wrong PIN: \"Incorrect trade PIN. 2 attempts left\"; second: \"Incorrect trade PIN. 1 attempt left\"","Third: \"Trade PIN locked after 3 incorrect attempts. Reset the demo data to unlock.\""]}]},{"id":"WCH","title":"Watchlists","summary":"Named lists of tickers with live quotes.","reqs":[{"id":"FR-WCH-01","weight":"Must","text":"Each watchlist row shows the last price and the day change measured against the previous close, with the 52-week range.","accept":["NB 100 index: 4,800.00, change -10.56 (-0.22%)","Core holdings in order: NBRX $142.30 -4.10%, ORIN $88.75 +0.26%, KELP $58.40 +0.05%, HALC $76.10 +1.56%, SOLR $26.45 -4.72%, DRVN $109.90 +0.71%","NBRX 52-week range $127.17 to $217.29"]},{"id":"FR-WCH-02","weight":"Could","text":"The Momentum list is separate, with gains green with an up arrow and losses red with a down arrow, and a range bar marker per row.","accept":["Momentum rows: QUVA $214.60 +1.73%, TELM $63.20 +0.83%, ARCS $176.40 -0.34%, LITH $38.60 +0.89%, MRIN $61.45 -1.00%","Direction is never shown by colour alone"]},{"id":"FR-WCH-03","weight":"Should","text":"A ticker can be added to a watchlist; duplicates and empty input are refused.","accept":["Adding QUVA appends a seventh row at $214.60 +1.73%","Adding it again shows an error and the list keeps seven rows","Empty input: \"Enter a ticker symbol\""]},{"id":"FR-WCH-04","weight":"Should","text":"Rows can be moved up or down and removed, and the result persists in the session.","accept":["Removing a row shows the toast \"<SYM> removed from Core holdings\"","The first row’s \"Move up\" is disabled","After navigating away and back the order and membership are unchanged"]}]},{"id":"SYM","title":"Symbol page and chart","summary":"Quote, statistics, order book and candlestick chart.","reqs":[{"id":"FR-SYM-01","weight":"Must","text":"The symbol header shows the company name, last price, change from previous close, bid and ask, sector, session and the held position.","accept":["NBRX: \"Northbeam Robotics\", $142.30, -$6.08, -4.10%, \"Bid $142.27 · Ask $142.33\", Technology, Open, 40 shares held"]},{"id":"FR-SYM-02","weight":"Should","text":"The Statistics card and ten-level order book match the market data.","accept":["NBRX: Open $144.05, Previous close $148.38, Day range $142.06 – $144.09, Volume 322,143, Average volume 4.20M, Market cap $91.07B, P/E ratio 34.5, Dividend yield —","Best bid $142.27 (200), $142.26 (1,900), $142.25 (2,800); best ask $142.33 (1,300), $142.34 (1,500)","Ten bid and ten ask levels"]},{"id":"FR-SYM-03","weight":"Should","text":"Chart range tabs 1D, 5D, 1M and 1Y redraw the chart, and SMA 20 and SMA 50 toggles add overlay lines.","accept":["The selected range shows as selected; returning to 1D restores intraday candles","Both SMA toggles show pressed and two overlay lines are drawn"]},{"id":"FR-SYM-04","weight":"Could","text":"Candles are green or red with wicks, volume bars and a dashed previous-close line; hovering shows a crosshair with an OHLC readout that disappears when the pointer leaves.","accept":["Last NBRX 1D candle: O $142.42 H $142.46 L $142.26 C $142.30, Vol 13,586"]}]},{"id":"ORD","title":"Order ticket","summary":"Entering, validating and placing orders.","reqs":[{"id":"FR-ORD-01","weight":"Must","text":"A market buy shows an estimate at the ask, fills at the ask after the PIN step, debits cash and adds shares.","accept":["5 NBRX: Estimated price (ask) $142.33, cost $711.65, commission $0.65, total $712.30","Review shows Buying power after $3,814.40; toast \"Order ORD-1005 placed: Buy 5 NBRX\"","Topbar buying power $3,814.40; History shows ORD-1005 Filled; the NBRX position is 45 shares"]},{"id":"FR-ORD-02","weight":"Must","text":"An order whose cost exceeds buying power is refused before review.","accept":["40 QUVA: \"Insufficient buying power: this order needs $8,586.65 but you have $4,526.70 available\"; no review dialog"]},{"id":"FR-ORD-03","weight":"Must","text":"Buying power equals cash minus the cash reserved by working orders.","accept":["Seeded account: Cash $11,847.35, $7,320.65 reserved by open orders, Buying power $4,526.70"]},{"id":"FR-ORD-04","weight":"Must","text":"A limit buy fills only at or below its limit price; otherwise it rests as Working.","accept":["Limit $142.00 against an ask of $142.33 (10 NBRX): estimated cost $1,420.00, total $1,420.65; status Working with nothing filled","A limit of $142.30 against the same ask also stays Working and does not fill at $142.33","The order remains Working after the clock advances while the ask stays above the limit"]},{"id":"FR-ORD-05","weight":"Should","text":"The ticket validates quantity, dollar amount and prices with specific messages.","accept":["Empty or fractional quantity: \"Enter a whole number of shares\"","Quantity 0: \"Quantity must be a whole number between 1 and 10,000\"","Missing limit: \"Enter a limit price like 142.50\"; missing stop: \"Enter a stop price like 138.00\"","Dollar amount below $1: \"Minimum dollar order is $1.00\""]},{"id":"FR-ORD-06","weight":"Could","text":"A dollar-amount order buys fractional shares at the ask.","accept":["$250 of NBRX: Estimated shares 1.7564, cost $250.00, commission $0.65, total $250.65; Buying power after $4,276.05"]},{"id":"FR-ORD-07","weight":"Could","text":"Ticket fields follow the order type.","accept":["Market: Quantity and Order by only; Limit adds Limit price; Stop adds Stop price; Stop limit shows both","Sell uses the labels \"Estimated proceeds\" and \"Estimated net proceeds\""]},{"id":"FR-ORD-08","weight":"Could","text":"The Review order dialog is a centred modal with muted labels, right-aligned values, an emphasised total, a secondary Back button and a filled Place order button with focus; Escape closes it and keeps the ticket values."},{"id":"FR-ORD-09","weight":"Should","text":"A pattern day trader warning appears once the account has made three day trades in five trading days and must be acknowledged before the next order.","accept":["Portfolio Margin card shows \"Day trades (5 days)\" as \"2 of 3\" before and \"3 of 3\" after a same-day buy and sell","Warning: \"Pattern day trader warning: you have made 3 day trades in the last 5 trading days. Another day trade may restrict your account.\"","\"Place order\" stays disabled until \"I understand the pattern day trader warning\" is ticked"]}]},{"id":"OPN","title":"Open orders","summary":"Working orders, fills, cancel and modify.","reqs":[{"id":"FR-OPN-01","weight":"Should","text":"Open orders lists working orders newest first with fill progress; History lists completed ones.","accept":["Open: ORD-1004 ORIN Sell Limit $95.00 Working; ORD-1003 HALC Buy Limit $77.00 Partially filled (\"40 of 100\"); ORD-1002 NBRX Sell Stop $120.00 Working; ORD-1001 KELP Buy Limit $54.00 Working","History: ORD-999 ORIN Buy Market Filled, ORD-998 TELM Buy Limit Cancelled, ORD-997 SOLR Buy Market Filled"]},{"id":"FR-OPN-02","weight":"Must","text":"A partially filled order fills further on each market tick until complete, then moves to History.","accept":["+1m: clock 10:01, toast \"Bought 40 HALC at $76.13 — partial fill\", order shows \"80 of 100\"","+1m again: toast \"Bought 20 HALC at $76.23 — order filled\"; the order leaves Open and the Orders count becomes 3","History shows ORD-1003 Filled \"100 of 100\""]},{"id":"FR-OPN-03","weight":"Should","text":"Cancelling an order asks for confirmation and moves it to History as Cancelled.","accept":["Dialog \"Cancel order?\" with \"Keep order\" and \"Cancel order\"","Toast \"Order ORD-1001 cancelled\"; three open orders remain"]},{"id":"FR-OPN-04","weight":"Should","text":"A working order’s quantity and limit price can be modified and the change is validated.","accept":["Empty limit: \"Enter a limit price like 54.00\"","Saving 96.00 on ORD-1004 shows \"Order ORD-1004 modified\" and the order stays Working at $96.00"]},{"id":"FR-OPN-05","weight":"Must","text":"A sell stop triggers only when the price falls to the stop price, never while the price is above it.","accept":["NBRX sell stop at $120.00 stays Working while NBRX trades near $142 and four open orders remain after a market tick"]}]},{"id":"PRT","title":"Portfolio","summary":"Equity, positions, margin and allocation.","reqs":[{"id":"FR-PRT-01","weight":"Must","text":"Portfolio shows equity, day change, unrealised P/L, cash, buying power and per-position figures that reconcile.","accept":["Total equity $40,496.45, Day change -$346.71 (-1.20%), Unrealised P/L +$1,288.39 (+4.71%), Cash $11,847.35, Buying power $4,526.70 (\"$7,320.65 reserved by open orders\")","KELP 100 avg $55.10 last $58.40 value $5,840.00 +$330.00; NBRX 40 avg $132.41 last $142.30 value $5,692.00 +$395.50; SOLR 153.5217 avg $24.37 last $26.45 value $4,060.65 +$320.04","Margin requirement $8,594.73, Margin excess $31,901.72, Day trades (5 days) \"2 of 3\", Realised P/L all time +$450.75"]},{"id":"FR-PRT-02","weight":"Could","text":"Sector allocation is a flat-colour donut with a legend whose percentages match the slices.","accept":["Technology 27.2%, Healthcare 12.2%, Financials 14.4%, Energy 10.0%, Industrials 6.9%, Cash 29.3% (largest)"]}]},{"id":"STM","title":"Activity and statements","summary":"Ledger of trades, deposits and dividends, with FIFO realised profit.","reqs":[{"id":"FR-STM-01","weight":"Must","text":"A sale closes the oldest purchase lots first, and realised profit equals proceeds minus the cost of those lots.","accept":["Sell 30 ORIN at market: estimated price (bid) $88.73, proceeds $2,661.90, net proceeds $2,661.25","Statements: one lot LOT-003, 30 shares at $79.20, Cost basis $2,376.00, P/L +$285.90","Realised P/L (FIFO) rises from +$450.75 to +$736.65; 30 ORIN shares remain"]},{"id":"FR-STM-02","weight":"Should","text":"Statements show realised sales, totals and lot detail for This month and All time.","accept":["All time: Realised P/L (FIFO) +$450.75, Trades 8, Commissions $5.20, Dividends $31.25, Deposits $5,000.00","Sales: MRIN 30 -$21.00, TELM 50 +$42.50, ARCS 20 +$346.00, TRNV 15 +$83.25","MRIN lot LOT-014: 30 shares at $63.10, proceeds $1,872.00, basis $1,893.00, P/L -$21.00; ARCS lot LOT-015: 20 at $171.10, proceeds $3,768.00, basis $3,422.00, P/L +$346.00"]},{"id":"FR-STM-03","weight":"Should","text":"Activity lists ten entries newest first and the Type filter narrows them.","accept":["First entry: HALC buy 40 at $76.10 on Mon 19 Oct, then MRIN sell 30 at $62.40","Trades 8 rows, Deposits 1 (\"ACH deposit\"), Dividends 1 (\"KELP dividend $0.3125/sh\"), All activity 10"]}]},{"id":"ALR","title":"Price alerts","summary":"Alerts that trigger as market time advances.","reqs":[{"id":"FR-ALR-01","weight":"Must","text":"An alert triggers when the price crosses its threshold as market time advances, rings the bell and shows a toast.","accept":["Seeded: AL-1 NBRX above $145.00 and AL-2 KELP below $58.00, both Active, no bell badge","At 14:00 both are still Active; at 15:00 the toast \"Alert: NBRX rose above $145.00 (now $145.14)\" appears, the bell shows 1, AL-1 is Triggered","\"Clear triggered\" removes AL-1 and the badge"]},{"id":"FR-ALR-02","weight":"Could","text":"Alerts can be created with validation and deleted after confirmation.","accept":["Empty price: \"Enter a price like 145.00\"","Creating KELP at or above 60.00 shows \"Alert set: KELP above $60.00\" and a new Active row","Delete asks \"Delete alert?\" with \"Keep alert\" and \"Delete alert\""]}]},{"id":"CLK","title":"Market time","summary":"The simulated market clock and sessions.","reqs":[{"id":"FR-CLK-01","weight":"Should","text":"The Market time control advances the clock by step or continuously and the session label follows the time.","accept":["+1m: 10:00 to 10:01; +1h: 10:01 to 11:01","Play advances about one minute per second until Pause; the button then reads \"Play\"","After 16:00 the label changes from \"Open\" to \"After-hours\""]}]}],"nfr":[{"id":"NFR-01","weight":"Must","text":"Day change and percentage change are measured from the previous close, never from today’s open.","accept":["NBRX shows -4.10% in Watchlists and on its symbol page; SOLR shows -4.72%"]},{"id":"NFR-02","weight":"Could","text":"The app is dark by default and offers a light theme that stays legible and persists after reload.","accept":["Toggle reads \"Switch to light theme\" / \"Switch to dark theme\""]},{"id":"NFR-03","weight":"Could","text":"Keyboard support: \"/\" focuses Search symbol, Enter opens the symbol, Enter in Quantity opens Review order with focus on Place order, Escape closes dialogs."},{"id":"NFR-04","weight":"Could","text":"At 390 px width the app keeps one column, bottom navigation and no horizontal page scroll; summary tiles wrap two per row."}],"glossary":[{"term":"Buying power","meaning":"Cash minus cash reserved by working buy orders."},{"term":"FIFO","meaning":"First in, first out: sales consume the oldest purchase lots first."},{"term":"Working order","meaning":"An accepted order that has not filled completely and has not been cancelled."}],"outOfScope":["Real market data","Real brokerage settlement","Native mobile apps"]}]