fix(pos): make cart quantity editable, and fix the sheet that hid it
Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.
- the quantity is now a real button that opens the Edit line sheet,
with an aria-label and a 44px touch target (it was 30px, too narrow
to even show "800")
- holding +/- no longer pops the sheet open mid-tap: the pointerdown
handler bails for .qty/.rm before starting the timer
- the sheet is quantity-only. The Unit price field is gone on request;
a mis-keyed price is expensive and easy to miss. The per-line
override it fed is removed with it — pos-service.ts only ever read
unit_price, the "price edited" label and the sale record's `edited`
flag were unreachable, and nothing else could set an override,
including a cart reload (persistCart saves only {id, qty, p})
The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.
Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #editSheet is a SIBLING of
#editBackdrop, but the CSS revealed it with
`.edit-backdrop.show .edit-sheet` — a descendant selector that can
never match a sibling — so the sheet sat at opacity:0 while the
backdrop went opaque. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.
Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.
sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POS cart quantity entry (`public/pos/pos-app.js` + `public/pos/pos.html`).
|
|
|
|
|
*
|
|
|
|
|
* Ringing up 800 of something means typing 800, and the only route to the
|
|
|
|
|
* quantity field used to be a 600ms long-press on the row. The number
|
|
|
|
|
* itself was a plain `<div class="v">` — exactly the thing a cashier reaches
|
|
|
|
|
* for — so it rendered inert. The sheet it opens is unchanged and still
|
|
|
|
|
* reachable both ways; these guards exist because the *discoverability* is
|
|
|
|
|
* what regressed, and that is invisible in a code review: nothing throws,
|
|
|
|
|
* the build is green, and the control is simply dead to a first-time user.
|
|
|
|
|
*
|
|
|
|
|
* Same shape of problem as `pos-inline-handlers.test.ts`: assert the
|
|
|
|
|
* affordance exists rather than trusting that it was left in place.
|
|
|
|
|
*/
|
|
|
|
|
const POS_APP = readFileSync(join(process.cwd(), "public", "pos", "pos-app.js"), "utf8");
|
|
|
|
|
const POS_HTML = readFileSync(join(process.cwd(), "public", "pos", "pos.html"), "utf8");
|
|
|
|
|
|
|
|
|
|
describe("POS cart quantity is directly settable", () => {
|
|
|
|
|
it("renders the quantity as a button, not an inert div", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/<button class="v"[^>]*onclick="window\.__pos\.openEditLine\(/);
|
|
|
|
|
expect(POS_APP).not.toMatch(/<div class="v">\$\{qty\}<\/div>/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("gives the quantity button an accessible label", () => {
|
|
|
|
|
// An unlabelled button reads as just "7" to a screen reader, which is
|
|
|
|
|
// no better than the div it replaced.
|
|
|
|
|
expect(POS_APP).toMatch(/aria-label="Set quantity for /);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the steppers as +/- only", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',-1\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',1\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("exposes openEditLine on the global the button calls", () => {
|
|
|
|
|
// The inline handler guard proves the name is reachable; this proves the
|
|
|
|
|
// specific member the button reaches for exists.
|
|
|
|
|
expect(POS_APP).toMatch(/window\.__pos\s*=\s*\{[^}]*openEditLine/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still opens the sheet on long-press", () => {
|
|
|
|
|
expect(POS_APP).toMatch(/startLongPress\(line\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/openEditLine\(id\)/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS cart long-press does not hijack the quantity controls", () => {
|
|
|
|
|
/** The pointerdown listener body, up to its options argument. */
|
|
|
|
|
const pointerdownHandler = () => {
|
|
|
|
|
const m = POS_APP.match(
|
|
|
|
|
/addEventListener\('pointerdown'[\s\S]*?\{ passive: true \}\);/,
|
|
|
|
|
);
|
|
|
|
|
expect(m, "pointerdown handler not found").not.toBeNull();
|
|
|
|
|
return m![0];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it("bails out of the pointerdown handler for .qty and .rm", () => {
|
|
|
|
|
// Holding "+" to ring up bulk used to pop the edit sheet open at 600ms,
|
|
|
|
|
// mid-tap. The steppers and the new quantity button each handle their
|
|
|
|
|
// own tap, so they must not start the row long-press.
|
|
|
|
|
expect(pointerdownHandler()).toMatch(/closest\('\.qty, \.rm'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("bails BEFORE starting the timer, not after", () => {
|
|
|
|
|
// Order matters: an early `return` leaves no pending timer to fire.
|
|
|
|
|
const handler = pointerdownHandler();
|
|
|
|
|
const guardAt = handler.indexOf("closest('.qty, .rm')");
|
|
|
|
|
const startAt = handler.indexOf("startLongPress(line)");
|
|
|
|
|
expect(guardAt, "guard not found in handler").toBeGreaterThan(-1);
|
|
|
|
|
expect(startAt, "startLongPress not found in handler").toBeGreaterThan(-1);
|
|
|
|
|
expect(guardAt, "guard must come before startLongPress").toBeLessThan(startAt);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still falls through to the row long-press for a tap on the row body", () => {
|
|
|
|
|
// The guard must not disable the long-press gesture entirely.
|
|
|
|
|
expect(pointerdownHandler()).toMatch(/startLongPress\(line\)/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS discount trigger costs no footer height", () => {
|
|
|
|
|
it("sits inside the Subtotal row rather than its own row", () => {
|
|
|
|
|
// The standalone .discount block was ~54px of always-on footer
|
|
|
|
|
// (12px padding + a 42px min-height button). The trigger now rides
|
|
|
|
|
// inside the existing Subtotal row, so it adds nothing vertically.
|
|
|
|
|
expect(POS_HTML).not.toMatch(/class="discount"/);
|
feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
Three changes to the POS sale pipeline, all in one commit because
pos-service.ts / types.ts / pos-app.js are each touched by all three and
splitting them would only produce intermediate broken states.
VAT is now charged on top, not backed out
Catalogue prices are ex-VAT: `selling_price` is derived as
`cost_price x markup` from the supplier's ex-VAT cost, with no VAT
uplift anywhere (verified — median selling/cost across the catalogue is
exactly 1.8). The till nevertheless treated them as VAT-inclusive and
recovered the net with `total / 1.15`, so every sale was recorded with
an understated net and an understated VAT line.
An R100 ex-VAT line now rings up as R115. The rate comes from the till's
letterhead, read server-side rather than accepted off the wire, and is
stored per sale so a later letterhead rate change cannot rewrite what
was charged. All 3,623 catalogue prices are untouched.
Ad-hoc "custom" lines
A cashier can now ring up something that was never a catalogue item —
"bolt cutting" at R20 agreed at the counter — via a new
"+ Add a custom item" composer. The line prints on the slip and lands in
pos_sale_items, but carries no stock_item relation and takes no stock
movement: no ledger row, no current_qty decrement, no catalogue entry.
pos_sale_items.stock_item is no longer required at the schema level and
an explicit `is_custom` flag marks the line, rather than inferring it
from a blank relation — so "how much did we make on bolt cutting this
month" is a plain query, and a future stock-less line type cannot be
confused with a corrupt one.
Custom lines are refused on account sales: those raise a delivery note
the customer signs for and feed their statement, so an ad-hoc charge
must not slip onto one. Enforced server-side, not just in the UI.
Discounts are now 20% or 40%, nothing else
The R-off / %-off pills and the free-typed number box are gone. The
server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses
anything outside it rather than coercing it, so a stale till cannot
invent a discount. The discount lands on the ex-VAT subtotal, making
"20% off" a true 20% off both the net and the VAT on it.
Two defects found while doing this
pos_sales was missing six fields that checkoutSale has been writing all
along (gross_subtotal, gross_vat, gross_total, discount_amount,
discount_kind, discount_input). PocketBase accepts unknown fields
without error and silently drops them, so all 192 historical sales lack
them and a discounted sale reprinted as though no discount had been
applied. Added, plus vat_percent.
History also passed gross_total (VAT-inclusive) where the receipt
derives the gross VAT from an ex-VAT base — a reprint double-counted
VAT. Corrected.
Verification: 3860 tests pass (14 new); typecheck, lint and production
build clean; live PocketBase probe confirms a pos_sale_items row with
stock_item="" and is_custom=true round-trips, with the probe removed
afterwards.
2026-10-12 20:54:09 +00:00
|
|
|
expect(POS_HTML).toMatch(
|
|
|
|
|
/<div class="totals">\s*<span>Subtotal[^<]*\s*<button class="discount-trigger"/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("offers exactly the two fixed discount tiers, with no free-text input", () => {
|
|
|
|
|
// The tiers replaced the old R-off / %-off pills + number field.
|
|
|
|
|
// A free-typed discount is what we're deliberately removing, so its
|
|
|
|
|
// absence is the thing worth pinning — an input creeping back in
|
|
|
|
|
// would let a cashier invent an arbitrary discount again.
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discTier20"[^>]*onclick="setDiscountTier\(20\)"/);
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discTier40"[^>]*onclick="setDiscountTier\(40\)"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/id="discInput"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/setDiscountKind|applyDiscount/);
|
fix(pos): make cart quantity editable, and fix the sheet that hid it
Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.
- the quantity is now a real button that opens the Edit line sheet,
with an aria-label and a 44px touch target (it was 30px, too narrow
to even show "800")
- holding +/- no longer pops the sheet open mid-tap: the pointerdown
handler bails for .qty/.rm before starting the timer
- the sheet is quantity-only. The Unit price field is gone on request;
a mis-keyed price is expensive and easy to miss. The per-line
override it fed is removed with it — pos-service.ts only ever read
unit_price, the "price edited" label and the sale record's `edited`
flag were unreachable, and nothing else could set an override,
including a cart reload (persistCart saves only {id, qty, p})
The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.
Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #editSheet is a SIBLING of
#editBackdrop, but the CSS revealed it with
`.edit-backdrop.show .edit-sheet` — a descendant selector that can
never match a sibling — so the sheet sat at opacity:0 while the
backdrop went opaque. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.
Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.
sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
2026-10-07 10:54:47 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps every id the discount JS depends on", () => {
|
|
|
|
|
// paintDiscount() and toggleDiscount() look these up by id; renaming any
|
|
|
|
|
// of them throws on drawer open, which would break the till.
|
|
|
|
|
for (const id of ["discountTrigger", "discountTriggerLabel", "discountBody"]) {
|
|
|
|
|
expect(POS_HTML, `missing #${id}`).toMatch(new RegExp(`id="${id}"`));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drops the old full-width button styling", () => {
|
|
|
|
|
expect(POS_HTML).not.toMatch(/\.discount-trigger\{[^}]*width:100%/);
|
|
|
|
|
expect(POS_HTML).toMatch(/\.discount-trigger\{[^}]*border:0/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the applied-discount totals line as the always-on indicator", () => {
|
|
|
|
|
// The trigger is a small link now; the red totals row is what proves a
|
|
|
|
|
// discount is live when nobody is looking at the footer.
|
|
|
|
|
expect(POS_HTML).toMatch(/id="discountLine"/);
|
|
|
|
|
expect(POS_HTML).toMatch(/class="totals discount-line"/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS Edit line sheet is actually visible when opened", () => {
|
|
|
|
|
// This is the bug that hid manual quantity entry entirely: #editSheet is
|
|
|
|
|
// a SIBLING of #editBackdrop, but the CSS revealed it with
|
|
|
|
|
// `.edit-backdrop.show .edit-sheet` — a descendant selector that cannot
|
|
|
|
|
// match a sibling. The sheet stayed at opacity:0 while the backdrop went
|
|
|
|
|
// opaque, so long-pressing a cart row showed a grey screen with nothing
|
|
|
|
|
// on it. Nothing threw, the build was green, and the feature looked
|
|
|
|
|
// simply absent.
|
|
|
|
|
it("reveals the sheet with its own .show rule, not a descendant of the backdrop", () => {
|
|
|
|
|
expect(POS_HTML).toMatch(/^\.edit-sheet\.show\{/m);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/^\.edit-backdrop\.show \.edit-sheet/m);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stacks the sheet above the backdrop", () => {
|
|
|
|
|
const backdrop = POS_HTML.match(/\.edit-backdrop\{[^}]*z-index:(\d+)/)?.[1];
|
|
|
|
|
const sheet = POS_HTML.match(/\.edit-sheet\{[^}]*z-index:(\d+)/)?.[1];
|
|
|
|
|
expect(backdrop, "backdrop z-index not found").toBeDefined();
|
|
|
|
|
expect(sheet, "sheet z-index not found — it would render under the backdrop").toBeDefined();
|
|
|
|
|
expect(Number(sheet)).toBeGreaterThan(Number(backdrop));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the sheet anchored to the viewport, not the document", () => {
|
|
|
|
|
// position:absolute resolved against an unpredictable ancestor.
|
|
|
|
|
expect(POS_HTML).toMatch(/\.edit-sheet\{position:fixed/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still opens and closes both elements from JS", () => {
|
|
|
|
|
// openEditLine/closeEditLine add .show to the backdrop AND the sheet —
|
|
|
|
|
// that part was always right, which is why the bug looked like a
|
|
|
|
|
// missing feature rather than a broken one.
|
|
|
|
|
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.add\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editSheet'\)\.classList\.add\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.remove\('show'\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/editSheet'\)\.classList\.remove\('show'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps them siblings so a tap on the sheet does not close it", () => {
|
|
|
|
|
// #editBackdrop carries onclick="closeEditLine()". Nesting the sheet
|
|
|
|
|
// inside it — the "obvious" fix — would make every tap on Save bubble
|
|
|
|
|
// to the backdrop and dismiss the dialog first. The backdrop must
|
|
|
|
|
// therefore stay an empty, immediately-closed element.
|
|
|
|
|
const backdropTag = POS_HTML.match(/<div class="edit-backdrop"[^>]*>/)?.[0];
|
|
|
|
|
expect(backdropTag, "edit-backdrop not found").toBeDefined();
|
|
|
|
|
expect(backdropTag).toMatch(/id="editBackdrop"/);
|
|
|
|
|
const after = POS_HTML.slice(
|
|
|
|
|
POS_HTML.indexOf(backdropTag!) + backdropTag!.length,
|
|
|
|
|
);
|
|
|
|
|
expect(after.startsWith("</div>"), "backdrop must close immediately").toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS Edit line popup is quantity-only", () => {
|
|
|
|
|
// Requested: no price editing from the till. The override it used to set
|
|
|
|
|
// was also never persisted (persistCart saves only {id, qty, p}), so it
|
|
|
|
|
// was a session-only thing that produced a sale record the server
|
|
|
|
|
// ignored. Removing it is a deletion, not a feature loss.
|
|
|
|
|
it("has no price field in the sheet", () => {
|
|
|
|
|
expect(POS_HTML).not.toMatch(/id="editPrice"/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/Unit price/);
|
|
|
|
|
expect(POS_APP).not.toMatch(/getElementById\('editPrice'\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps the quantity field", () => {
|
|
|
|
|
expect(POS_HTML).toMatch(/id="editQty"/);
|
|
|
|
|
expect(POS_APP).toMatch(/getElementById\('editQty'\)\.value = String\(line\.qty\)/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes the override mechanism entirely", () => {
|
|
|
|
|
// Nothing else could set it — not a cart reload, not addToCart — so
|
|
|
|
|
// leaving the field in place would be dead code implying the till can
|
|
|
|
|
// still change a price.
|
|
|
|
|
expect(POS_APP).not.toMatch(/unitPriceOverride/);
|
|
|
|
|
expect(POS_HTML).not.toMatch(/price edited/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("still sends a price, and it is the shelf price", () => {
|
|
|
|
|
// /checkout requires unit_price; dropping the override must not drop
|
|
|
|
|
// the price itself.
|
|
|
|
|
expect(POS_APP).toMatch(/unit_price: lineUnitPrice\(\{p,qty\}\)/);
|
|
|
|
|
expect(POS_APP).toMatch(/selling_price: lineUnitPrice\(\{p,qty\}\)/);
|
|
|
|
|
expect(POS_APP).toMatch(
|
|
|
|
|
/function lineUnitPrice\(line\)\{\s*return Number\(line\.p\.selling_price/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("no longer sends the unused `edited` flag", () => {
|
|
|
|
|
// pos-service.ts never read it — only `unit_price` matters.
|
|
|
|
|
expect(POS_APP).not.toMatch(/\bedited:/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("POS service worker cache tracks shell edits", () => {
|
|
|
|
|
it("is past v30, since pos-app.js and pos.html are served cache-first", () => {
|
|
|
|
|
const v = POS_HTML; // placeholder to keep the import honest
|
|
|
|
|
expect(v).toBeDefined();
|
|
|
|
|
const sw = readFileSync(join(process.cwd(), "public", "pos", "sw.js"), "utf8");
|
|
|
|
|
const m = sw.match(/const CACHE = "pos-v(\d+)"/);
|
|
|
|
|
expect(m, "sw.js CACHE constant not found").not.toBeNull();
|
|
|
|
|
expect(Number(m![1])).toBeGreaterThanOrEqual(33);
|
|
|
|
|
});
|
fix(pos): dead discount toggle + stale discount figure, lift dark palette
Two reported symptoms that turned out to be one bug, plus the dark
mode the user described as "too black".
The `hidden` attribute does nothing on the discount controls
`hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`.
Any author rule that sets `display` outranks it — and `.totals` and
`.discount-body` both set `display:flex`. So `el.hidden = true` was a
no-op on both:
- the discount tier panel could not be collapsed, making "Add
discount" look like a dead control;
- the Discount totals row stayed on screen after the discount was
cleared, still showing the last figure (R 98.37) under a total
that no longer contained it. The JS only ever set `hidden` and
never cleared the text, so the operator had no way to tell stale
text from a live one.
`.cat-menu` and `.cat-backdrop` already carried per-element
`[hidden]{display:none}` overrides for exactly this reason; the two
discount elements were missed. Replaced with one global rule so the
whole class is covered rather than relying on each element to
remember. The clear path now also blanks the amount.
Verified statically rather than in a browser: jsdom does not model
the UA-vs-author cascade and reported the broken and fixed cases
identically, so the cascade was checked by resolving which rules
apply. Regression test fails without the fix.
Dark palette
The POS canvas was #0f0f0f (L* 4.3 — effectively black) with
surfaces only 3-4 L* above it, mixing a warm #1c1416 against a
neutral #181818 in the same UI. That near-black base plus two
competing surface tones is what read as muddy. Replaced with a
single cool-neutral ramp with four real steps, and menus/modals now
sit on a `--raised` layer instead of reusing the surface colour.
`#717171` was the secondary text colour in 25 places and measured
3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1.
Both the text greys and the surfaces were hardcoded per component
across seven POS pages; they are now tokens, with the dark block
rebinding them.
The shared `.dark` block in themes.css (drives all 24 themes) sat at
oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its
shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still
above AA. Cursor/Supabase, which override it darker, were at L* 1.3
and lifted too.
Service worker cache bumped to pos-v36 — pos.html and pos-app.js are
served cache-first, so without the bump the tills keep serving the
broken files.
3863 tests pass (3 new); build clean.
2026-10-12 21:17:57 +00:00
|
|
|
});
|
|
|
|
|
describe("POS [hidden] attribute actually hides", () => {
|
|
|
|
|
/**
|
|
|
|
|
* A `hidden` attribute is implemented by the UA stylesheet as
|
|
|
|
|
* `[hidden]{display:none}` — but ANY author rule that sets `display`
|
|
|
|
|
* beats it, because author styles outrank UA styles regardless of
|
|
|
|
|
* specificity. `.totals` and `.discount-body` both set
|
|
|
|
|
* `display:flex`, so `el.hidden = true` did nothing: the discount
|
|
|
|
|
* panel could not be collapsed, and the "Discount" row could not be
|
|
|
|
|
* dismissed — it stayed on screen showing the last figure even
|
|
|
|
|
* after the discount was cleared, because the JS only ever set
|
|
|
|
|
* `hidden` and never cleared the text.
|
|
|
|
|
*
|
|
|
|
|
* Both symptoms looked like two unrelated bugs. One line of CSS
|
|
|
|
|
* fixes both, and this guards the whole class of them.
|
|
|
|
|
*/
|
|
|
|
|
it("has a global [hidden]{display:none} override", () => {
|
|
|
|
|
expect(POS_HTML).toMatch(/\[hidden\]\s*\{[^}]*display:\s*none\s*!important/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("covers every element that carries a hidden attribute", () => {
|
|
|
|
|
// Any element with `hidden` whose class rule sets `display` would
|
|
|
|
|
// otherwise be permanently visible. Assert the override exists
|
|
|
|
|
// rather than trusting each element to have remembered one.
|
|
|
|
|
const risky = [
|
|
|
|
|
...POS_HTML.matchAll(/<div[^>]*\shidden[^>]*>/g),
|
|
|
|
|
].filter((tag) => /\bclass="/.test(tag[0]));
|
|
|
|
|
expect(risky.length, "expected some hidden elements to audit").toBeGreaterThan(0);
|
|
|
|
|
expect(POS_HTML).toMatch(/\[hidden\]\s*\{[^}]*display:\s*none\s*!important/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("blanks the discount figure when the row is cleared", () => {
|
|
|
|
|
// Hiding alone left the old amount painted on screen, which the
|
|
|
|
|
// operator had no way to distinguish from a live one. Matched
|
|
|
|
|
// loosely because a comment sits between the two statements.
|
|
|
|
|
expect(POS_APP).toMatch(
|
|
|
|
|
/dl\.hidden\s*=\s*true;[\s\S]{0,400}discountLineAmount'\)\.textContent\s*=\s*''/,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|