Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,86 @@ everything still outstanding is captured below.

---

## Marketing screenshot visual cleanup

*Origin: visual audit of the mobile Retina screenshots generated from
`../tickets-site/scripts/screenshots/` on 2026-07-18.*

The screenshots use real application pages with scenario-specific custom CSS.
Keep fixes in those scenarios unless the same problem also appears in the normal
application UI. Regenerate each affected PNG and inspect the final image at its
actual size before marking an item complete.

**High priority:**

- **Fix the logistics card overflow and buttons.** In
`scripts/screenshots/logistics.js`, the white booking card extends past the
right edge of its blue day panel in `logistics-deliveries.png`. Both orange
“Mark done” buttons are too narrow: the text touches the pill edges and looks
vertically cramped. Keep the card inside its parent with border-box sizing,
then give the buttons enough inline and block padding for the full label.
- **Give the daily-calendar states distinct, readable colours.** In
`scripts/screenshots/daily-events.js`, the selected 3 August date uses white
text on pale yellow in `daily-events-calendar.png`, which has poor contrast.
The selected date and the other highlighted date also look almost identical.
Use dark text and visibly different selected/today treatments while keeping
both states clear without relying on colour alone.
- **Remove accidental focus outlines from checkout captures.** Several images
end with whichever control was filled last still focused, producing an
unrelated black or white outline: `charity-family-fun-day-checkout.png`,
`promo-codes-and-add-ons-checkout.png`, `equipment-hire-booking.png`,
`the-tempest-group-checkout.png`, and
`garden-party-package-checkout.png`. Add one shared scenario helper that blurs
the active control before capture, then use it for all filled checkout
scenarios. Keep deliberate focus only in a screenshot that is specifically
demonstrating keyboard focus.
- **Stack the Garden Party email field on mobile.** In
`scripts/screenshots/packages.js`, “Your Email” and its input are squeezed
onto one row in `garden-party-package-checkout.png`, unlike the name field
above it. Make contact-field labels and controls consistently full-width so
the input does not crowd the label.

**Polish:**

- **Shorten the bulk-email preview.** In `scripts/screenshots/bulk-email.js`,
`bulk-email-preview.png` is about twice as tall as it needs to be because the
warning copy, line height, and section gaps are oversized. Reduce the
scenario font size/line height and vertical spacing without hiding or
rewriting the real warning. Keep the recipients, subject, warning, and full
message preview visible.
- **Tighten the balance summary.** In
`scripts/screenshots/deposits-and-balance-payments.js`, the three totals in
`deposits-and-balance-payments.png` have large vertical gaps and the payment
action sits in an oversized empty panel. Reduce those gaps and panel padding
while preserving a clear order: full price, already paid, balance due, then
payment action.
- **Tighten oversized checkout headings.** The headings in
`charity-family-fun-day-checkout.png` and `equipment-hire-booking.png` wrap
with more line spacing than the forms use. Adjust only the scenario heading
line-height and bottom margin so each title remains prominent but does not
dominate the image.
- **Use the theme colour for custom-question controls.** In
`scripts/screenshots/custom-questions.js`, the selected radio in
`custom-questions-checkout.png` uses the browser’s bright blue default, which
clashes with the brown bakery theme. Set `accent-color` to the scenario’s
accessible brown accent and confirm the selected state remains obvious.
- **Reduce the listing-form crop height if it stays readable.** In
`scripts/screenshots/listing-management.js`,
`summer-sessions-listing-form.png` is nearly 2,000 pixels tall despite
already being limited to the Basics fieldset. Tighten field hints, editor
height, and section spacing rather than removing the date or venue. Keep all
text comfortably readable at the rendered `split-image` size.

**Final visual check:**

- Regenerate every scenario-owned screenshot after the fixes and inspect for
clipped text, overflowing boxes, accidental focus rings, low contrast,
overlapping labels, inconsistent padding, and empty space at all four crop
edges. Also render the affected `split-image` pages at desktop and mobile
widths so a good source PNG is not undermined by its website placement.

---

## Split the database migration runtime

*Origin: CodeRabbit review on PR #1845 (`src/shared/db/migrations.ts`).*
Expand Down
104 changes: 56 additions & 48 deletions scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ src/shared/accounting/rows.ts:133:72 ?? → || # renderInsert guard?.args: In
src/shared/accounting/manual-entries.ts:209:29 ?? → || # guard error message `transfer.kind ?? ""`: string|undefined, "" ?? "" === "" || ""
src/shared/checkout-ledger.ts:35:72 ?? → || # find(...)?.amount: number|undefined; the only falsy-non-null amount is 0 and 0 ?? 0 === 0 || 0
src/features/settings-bundles.ts:264:40 ?? → || # bundle is a readonly string[] or undefined; every present array, including [], is truthy
src/shared/db/attendees/balance.ts:226:22 ?? → || # attendee status ids are positive integers when present, so the value is always truthy or nullish
src/shared/accounting/queries.ts:162:26 || → && # transferActivityBounds: MIN(occurred_at) and MAX(occurred_at) over one table are NULL together (both iff the table is empty), so either-null and both-null coincide
src/shared/admin-features.ts:95:58 ?? → || # find(): AdminFeatureDefinition|undefined; a feature object is always truthy
src/shared/db/admin-features.ts:50:10 __required_setting_condition__ → "" # the placeholder key is never stored: a failed condition always aborts on its NULL value
Expand Down Expand Up @@ -248,54 +249,61 @@ src/shared/payment-helpers.ts:159:24 ?? → || # toBookingItems intent.alloc
src/shared/settings/registry.ts:345:40 && → || # readOnly?: true; absent is falsy either way, present is true either way

# --- External order widget (src/ui/client/order.ts) ----------------------------
# This is browser DOM-construction code; many operator swaps are provably
# equivalent. Each `= → +=` below targets a property whose value before the
# assignment is fixed, so `x = v` and `x += v` produce the same result:
# * a freshly created element's `.className`/`.textContent` is always "" (DOM
# spec), so `"" + literal === literal`;
# * the boolean flags (`memoryOnly`, `dropped`, `hasVariable`) are `false`
# before the swapped assignment and are only ever read for truthiness, so
# `false + true === 1` is truthy just like `true`.
src/ui/client/order.ts:97:28 ?? → || # catalogEntry(): CatalogEntry|undefined; an object is truthy, undefined and "" never occur
src/ui/client/order.ts:136:22 = → += # memoryOnly: false → 1, only read for truthiness
src/ui/client/order.ts:152:24 = → += # memoryOnly: false → 1, only read for truthiness
src/ui/client/order.ts:163:22 = → += # memoryOnly: false → 1, only read for truthiness
src/ui/client/order.ts:177:16 = → += # dropped: false → 1, only read for truthiness
src/ui/client/order.ts:180:51 ?? → || # merged.get(): merged values are summed positive quantities (>0), never 0
src/ui/client/order.ts:183:18 = → += # this.notice was "" before this assignment
src/ui/client/order.ts:261:24 = → += # heading.textContent: fresh element, "" before
src/ui/client/order.ts:266:18 = → += # p.className: fresh element, "" before
src/ui/client/order.ts:267:20 = → += # p.textContent: fresh element, "" before
src/ui/client/order.ts:276:43 = → += # hasVariable: false → 1, only read for truthiness
src/ui/client/order.ts:283:22 = → += # total.className: fresh element, "" before
src/ui/client/order.ts:284:24 = → += # total.textContent: fresh element, "" before
src/ui/client/order.ts:289:23 = → += # caveat.className: fresh element, "" before
src/ui/client/order.ts:290:25 = → += # caveat.textContent: fresh element, "" before
src/ui/client/order.ts:296:24 = → += # empty.textContent: fresh element, "" before
src/ui/client/order.ts:305:18 = → += # row.className: fresh element, "" before
src/ui/client/order.ts:307:19 = → += # name.className: fresh element, "" before
src/ui/client/order.ts:308:21 = → += # name.textContent: fresh element, "" before
src/ui/client/order.ts:312:20 = → += # price.className: fresh element, "" before
src/ui/client/order.ts:313:22 = → += # price.textContent: fresh element, "" before
src/ui/client/order.ts:328:21 = → += # continue button.className: fresh element, "" before
src/ui/client/order.ts:329:23 = → += # continue button.textContent: fresh element, "" before
src/ui/client/order.ts:346:19 = → += # cart button.className: fresh element, "" before
src/ui/client/order.ts:347:16 = → += # button.hidden: false → 1 (truthy), and render() overwrites it with count===0 immediately
src/ui/client/order.ts:347:19 true → false # button.hidden is overwritten by render()'s `count === 0` on the next line of the constructor
src/ui/client/order.ts:349:18 = → += # count span.className: fresh element, "" before
src/ui/client/order.ts:350:20 = → += # count span.textContent: fresh element, "" before
src/ui/client/order.ts:352:20 = → += # label.textContent: fresh element, "" before
src/ui/client/order.ts:361:19 = → += # close button.className: fresh element, "" before
src/ui/client/order.ts:362:21 = → += # close button.textContent: fresh element, "" before
src/ui/client/order.ts:372:17 = → += # stepper wrap.className: fresh element, "" before
src/ui/client/order.ts:375:18 = → += # dec.textContent: fresh element, "" before
src/ui/client/order.ts:379:20 = → += # value span.textContent: fresh element, "" before
src/ui/client/order.ts:382:18 = → += # inc.textContent: fresh element, "" before
src/ui/client/order.ts:387:21 = → += # remove.textContent: fresh element, "" before
src/ui/client/order.ts:395:20 = → += # style.textContent: fresh element, "" before
src/ui/client/order.ts:429:27 = → += # registry[origin]: undefined → a truthy string; only read for the dedup truthiness check, the live controller is the local var
src/ui/client/order.ts:433:48 ?? → || # link.dataset.addListing: string|undefined; the only falsy-non-null value "" equals the "" fallback
src/ui/client/order.ts:443:59 ?? → || # link.dataset.addListing: string|undefined; the only falsy-non-null value "" equals the "" fallback
# URL matches and catalog entries are either non-empty strings/objects or absent,
# so these nullish and truthy fallbacks have the same result.
src/ui/client/order.ts:101:57 ?? → || # RegExp match group is a non-empty slug or undefined
src/ui/client/order.ts:108:36 ?? → || # catalogEntry returns a CatalogEntry object or undefined
src/ui/client/order.ts:213:51 ?? → || # merged quantities are positive integers, so an existing sum is truthy
src/ui/client/order.ts:64:37 0 → 1 # quantity 1 takes the fallback 1 instead of returning 1

# reconcile runs once on a new controller whose notice starts empty. The only
# production caller of add always passes parseAddQuantity's positive result.
src/ui/client/order.ts:216:18 = → += # empty notice plus the message equals the message
src/ui/client/order.ts:222:39 1 → 0 # add's default is unused; its only caller always supplies quantity

# Every `= → +=` below writes to a newly-created DOM element property whose DOM
# default is "". Concatenating the assigned text onto "" is exactly assignment.
src/ui/client/order.ts:294:24 = → += # fresh heading textContent
src/ui/client/order.ts:299:18 = → += # fresh notice paragraph className
src/ui/client/order.ts:300:20 = → += # fresh notice paragraph textContent
src/ui/client/order.ts:316:22 = → += # fresh subtotal paragraph className
src/ui/client/order.ts:317:24 = → += # fresh subtotal paragraph textContent
src/ui/client/order.ts:322:23 = → += # fresh caveat paragraph className
src/ui/client/order.ts:323:25 = → += # fresh caveat paragraph textContent
src/ui/client/order.ts:329:24 = → += # fresh empty paragraph textContent
src/ui/client/order.ts:338:18 = → += # fresh row className
src/ui/client/order.ts:340:19 = → += # fresh name span className
src/ui/client/order.ts:341:21 = → += # fresh name span textContent
src/ui/client/order.ts:345:20 = → += # fresh price span className
src/ui/client/order.ts:346:22 = → += # fresh price span textContent
src/ui/client/order.ts:360:23 = → += # fresh Continue button textContent
src/ui/client/order.ts:378:18 = → += # fresh count span className
src/ui/client/order.ts:379:20 = → += # fresh count span textContent
src/ui/client/order.ts:381:20 = → += # fresh cart label span textContent
src/ui/client/order.ts:389:21 = → += # fresh Close button textContent
src/ui/client/order.ts:399:17 = → += # fresh stepper span className
src/ui/client/order.ts:402:18 = → += # fresh decrease button textContent
src/ui/client/order.ts:406:20 = → += # fresh quantity span textContent
src/ui/client/order.ts:409:18 = → += # fresh increase button textContent
src/ui/client/order.ts:414:21 = → += # fresh Remove button textContent
src/ui/client/order.ts:422:20 = → += # fresh style element textContent

# buildButton's initial values are overwritten by the constructor's render
# before the button is exposed.
src/ui/client/order.ts:376:19 true → false # constructor render immediately sets hidden from the cart count
src/ui/client/order.ts:379:23 0 → "" # constructor render immediately writes the current numeric count

# The widget owns this global registry: before init it is undefined, afterwards
# it is an object. Both are mapped to the same object by these operators.
src/ui/client/order.ts:450:40 ?? → || # registry is Record<string, unknown>|undefined

# Dataset values are strings when present. At initial scan the selector requires
# the attribute; after enhancement, an absent value and either fallback string
# all fail both resolvers and preserve normal navigation.
src/ui/client/order.ts:466:40 ?? → || # present dataset string; empty maps to the same empty fallback
src/ui/client/order.ts:466:44 → "mutated" # fallback is unreachable for the matching selector
src/ui/client/order.ts:481:46 ?? → || # present strings are truthy or empty; both operators keep the same value
src/ui/client/order.ts:481:50 → "mutated" # absent fallback values resolve to neither listing nor package
src/features/public/site-nav.ts:105:54 ?? → || # membersByGroup.get(): ListingWithCount[]|undefined — an array is always truthy
src/shared/site-pages/core.ts:145:50 ?? → || # descendantsOf: forest.itemsByPage.get() → SitePageItem[]|undefined — an array is always truthy
src/shared/site-pages/core.ts:322:43 ?? → || # levelOf: forest.itemsByPage.get() → SitePageItem[]|undefined — an array is always truthy
Expand Down
17 changes: 13 additions & 4 deletions scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,16 +164,20 @@ const submit = async (page: Page, formSelector: string): Promise<void> => {
]);
};

const setupAdmin = async (page: Page, baseUrl: string): Promise<void> => {
const setupAdmin = async (
page: Page,
baseUrl: string,
username = USERNAME,
): Promise<void> => {
await page.goto(`${baseUrl}/setup/`);
await page.locator('[name="admin_username"]').fill(USERNAME);
await page.locator('[name="admin_username"]').fill(username);
await page.locator('[name="admin_password"]').fill(PASSWORD);
await page.locator('[name="admin_password_confirm"]').fill(PASSWORD);
await page.locator('[name="accept_agreement"]').check();
await submit(page, 'form[action="/setup/"]');

await page.goto(`${baseUrl}/`);
await page.locator('[name="username"]').fill(USERNAME);
await page.locator('[name="username"]').fill(username);
await page.locator('[name="password"]').fill(PASSWORD);
await submit(page, 'form[action="/admin/login"]');

Expand Down Expand Up @@ -345,7 +349,7 @@ const captureScenario = async (
baseUrl: string,
outputDir: string,
): Promise<void> => {
await setupAdmin(page, baseUrl);
await setupAdmin(page, baseUrl, scenario.setupUsername);
await applyTheme(
page,
baseUrl,
Expand All @@ -357,6 +361,11 @@ const captureScenario = async (
}`,
);
await scenario.run({
balancePathFor: async (attendeeId) => {
Deno.env.set("DB_ENCRYPTION_KEY", DB_KEY);
const { signBalanceToken } = await import("#shared/balance-link.ts");
return `/pay/${await signBalanceToken(attendeeId)}`;
},
baseUrl,
page,
submit: (formSelector) => submit(page, formSelector),
Expand Down
5 changes: 5 additions & 0 deletions scripts/screenshots/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { toFileUrl } from "@std/path";
import type { Page } from "playwright";

export interface ScreenshotScenarioContext {
balancePathFor: (attendeeId: number) => Promise<string>;
baseUrl: string;
page: Page;
submit: (formSelector: string) => Promise<void>;
Expand All @@ -13,6 +14,7 @@ export interface ScreenshotScenario {
fullPage?: boolean;
name: string;
run: (context: ScreenshotScenarioContext) => Promise<void>;
setupUsername?: string;
}

const isScenario = (value: unknown): value is ScreenshotScenario => {
Expand All @@ -23,6 +25,9 @@ const isScenario = (value: unknown): value is ScreenshotScenario => {
typeof candidate.name === "string" &&
/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(candidate.name) &&
typeof candidate.run === "function" &&
(candidate.setupUsername === undefined ||
(typeof candidate.setupUsername === "string" &&
candidate.setupUsername.trim().length > 0)) &&
(candidate.elementSelector === undefined ||
typeof candidate.elementSelector === "string") &&
(candidate.fullPage === undefined ||
Expand Down
1 change: 0 additions & 1 deletion src/features/admin/attendee-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,6 @@ export const buildTemplateData = async (
attendee?.remaining_balance ?? 0,
summary?.fullPrice ?? 0,
summary?.depositPaid ?? 0,
summary?.listedFullPrice ?? 0,
);
const warnings = await computeWarnings(parsed, attendee?.id);
const logistics = await buildAttendeeLogisticsData(parsed.lines, attendee);
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en/tickets.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"public.ticket.no_dates_available": "No dates are currently available for booking.",
"public.ticket.number_of_tickets": "Number of Tickets",
"public.ticket.choose_option": "Choose an option for {name}",
"public.ticket.choose_total": "Choose {count, plural, one {# add-on} other {# add-ons}} in total",
"public.ticket.choose_total": "Choose add-ons to match your ticket quantity.",
"public.ticket.child_from_price": "(from {price})",
"public.ticket.child_required": "Please choose an option for {name}.",
"public.ticket.child_too_few": "Choose {count, plural, one {# more add-on} other {# more add-ons}} for {name}.",
Expand Down
Loading
Loading