diff --git a/DESIGN.md b/DESIGN.md index 119ec09..dbd429c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -47,11 +47,11 @@ Panels and receipts are rectangular; controls use 3px corner radii and at least - `components.tsx`: `PageTitle` (optional eyebrow, rendered only when given; optional title character: picture and speech bubble), `Note`, `Empty`, `Addr`, `AddressLink`, `RoleInfo`, `TxButton`, `ActionStatus`, `DisabledReason`, `Receiver`, and the basket mark. Reuse these for hierarchy, warnings, readable addresses and guarded actions. - `Scenery.tsx`: `Marquee` and `StoreShelf`. The two decorative shelf rows use empty-alt local images: the first 14 food SVGs in manifest order above, then the next 19 below, each list repeated five times and clipped without motion. The light-blue back wall is `#c4e7ff`; red planks have ink edges. Desktop icons are 40px squares with 12px gaps in a 120px strip. Phones use 22px squares, 8px gaps and 3px planks in a 50px strip, with a 4px gap above the shelf frame; this preserves the previous above-fold messages at 375×812. Both shelf locations share this system. -- `Marquee` has two identical halves, each holding eight copies of the unchanged slogan line (720px wide in VT323 at 24px). Each half shows only its first k lines, k being the fewest whole lines that cover the window: 1 up to 720px wide, 2 up to 1440px, 3 up to 2160px, and so on up to 8 above 5040px. So there is no blank space in the window, the text runs on across the seam with the normal word gap, and the moving track is at most two screens plus two lines wide (no `will-change`; a very wide moving block stops being drawn on iPhones). Only `overflow: hidden` clips the window. It loops from 0 to −50% in k × 45 seconds (16px per second), or k × 11.25 seconds at 660px and below (64px per second). There is no pause, stop or hide control, per the owner’s decision. Animation runs only under `prefers-reduced-motion: no-preference`; reduced motion is static. +- `Marquee` has two identical halves, each holding eight copies of the unchanged slogan line (720px wide in VT323 at 24px). Each half shows only its first k lines, k being the fewest whole lines that cover the window: 1 up to 720px wide, 2 up to 1440px, 3 up to 2160px, and so on up to 8 above 5040px. So there is no blank space in the window and the text runs on across the seam with the normal word gap. Since v10 the two halves share one grid cell and each moves on its own, as a Web Animation started by `Marquee` (no CSS animation): from translateX(100%) to translateX(−100%) of its own width, linear and endless, the second half half a cycle behind the first. So the halves always sit edge to edge, each jumps back only while it is wholly off screen, and no `animationiteration` event fires at a reset. Each moving block is one half (k lines, at most the window plus one line) with no `will-change` and no 3D transform (a very wide moving block stops being drawn on iPhones); the track itself stays put. Only `overflow: hidden` clips the window. The speed is exactly 16px per second (a half's cycle is 2 × k × 45 seconds), 64px per second at 660px and below; the cycle is computed from the measured half width and re-timed when the width, the phone breakpoint or the web font changes. There is no pause, stop or hide control, per the owner’s decision. Under `prefers-reduced-motion: reduce` both animations are cancelled and the halves stand still side by side from the left edge. - Title characters: each picture keeps its manifest width/height ratio with `object-fit: contain`, bottom-aligned, standing on the checker strip (on her shelf on phones). The bubble's tail points at her mouth; the bubble stays clear of her face, hands and held items, and no word is split across lines. Speech stays still in white rounded bubbles with ink borders, in Flyer at 20px (18px at 999px and below, 16px on phones); copy retains its supplied case. Lines: Vault "I was hoping you would come through my aisle", Deposit "Go ahead and put it in", Redeem "are you sure that’s all you came here for?". Each picture has its assigned descriptive alt text. No image is cropped, stretched, recolored or animated. -- `Vault.tsx`: metric cards, the Stock shelves and `DepositState`. The Stock shelves are a store shelf unit (light-blue back wall, royal-blue uprights, the food strip as its top shelf) with every stock as a yellow shelf-edge label clipped onto a red shelf edge, in two groups under red shelf signs: "In the basket" (held, or held amount unreadable: big labels, biggest share first) and "On the shelf, not held" (small labels, listing order). Every label shows the ticker, the feed price as a red flyer price, when it updated ("just now" under a minute; the drawer keeps the full words) and the status in words (Open, Closed to deposits, Retired, or "Open · not taking deposits" with the price reason in a chip); big labels add the share sticker and bar, the value and the amount held. Only a feed price missing or too old (reason 9) is dimmed with an "old price" tag; under reasons 10 and 11 the price passed the age check, so it shows normally and the chip gives the reason. Warnings (check this pairing, balance check) print on the label. Tapping a label opens a drawer under its row with the feed, feed price and age, pool check, held amount, share, owed amount and the Stock Token and price feed links (Close or Escape). Filters All / Open / Closed / Held with counts and a search by ticker or address narrow the shelves; while one is on, "Show all" brings every stock back. Money uses `usd()`, cut to the cent like the NAV figure. The labels of each row swing once on their clips when the row first comes into view and lift 4px on hover or focus; nothing moves under reduced motion, and filtering or searching stops the swing for good. Price status comes from contract reasons (fifteen, in `BaskVault.Reason` order); reasons 9–11 explicitly say the pool check was not run. `DepositState` (also on Deposit) prints the hours in words (New York time, or UTC-5/UTC-4 under Dst 1/2), and for reason 3 says when deposits reopen (weekday, time, date, a countdown from block time and the visitor's local time) and for reason 14 that they wait for a price update, computed by `newYork.ts` from `settings()` and the latest block timestamp. Once the opening is reached it re-reads the vault and says deposits may be open. Unreadable values are spelled out, never shown as a real zero. +- `Vault.tsx`: metric cards, the Stock shelves and `DepositState`. The Stock shelves are a store shelf unit (light-blue back wall, royal-blue uprights, the food strip as its top shelf) with every stock as a yellow shelf-edge label clipped onto a red shelf edge, in two groups under red shelf signs: "In the basket" (held, or held amount unreadable: big labels, biggest share first) and "On the shelf, not held" (small labels, listing order). Every label shows the ticker, the feed price as a red flyer price and the status in words; no label shows when its price updated (feeds post only on a 0.5% move or every 24 hours, so a price time looked stale when it was not) (Open, Closed to deposits, Retired, or "Open · not taking deposits" with the price reason in a chip); big labels add the share sticker and bar, the value and the amount held. Only a feed price missing or too old (reason 9) is dimmed with an "old price" tag, without a time; under reasons 10 and 11 the price passed the age check, so it shows normally and the chip gives the reason. Warnings (check this pairing, balance check) print on the label. Tapping a label opens a drawer under its row with the feed, feed price (no age), pool check, held amount, share, owed amount and the Stock Token and price feed links (Close or Escape). Filters All / Open / Closed / Held with counts and a search by ticker or address narrow the shelves; while one is on, "Show all" brings every stock back. Money uses `usd()`, cut to the cent like the NAV figure. The labels of each row swing once on their clips when the row first comes into view and lift 4px on hover or focus; nothing moves under reduced motion, and filtering or searching stops the swing for good. Price status comes from contract reasons (fifteen, in `BaskVault.Reason` order); reasons 9–11 explicitly say the pool check was not run. `DepositState` (also on Deposit) prints the hours in words (New York time, or UTC-5/UTC-4 under Dst 1/2), and for reason 3 says when deposits reopen (weekday, time, date, a countdown from block time and the visitor's local time) and for reason 14 that they wait for a price update, computed by `newYork.ts` from `settings()` and the latest block timestamp. Once the opening is reached it re-reads the vault and says deposits may be open. Unreadable values are spelled out, never shown as a real zero. - `Docs.tsx`: plain explanations of the vault; an FAQ panel after Redemption answers what happens when a stock can’t be sent on redeem (the Redeem page itself carries no such note). -- `Flows.tsx`: decimal amount fields, exact Use full balance controls, search-to-add stocks, previews immediately after selected amounts, named approval steps and owed-first claim cards. Claim all precedes the cards; zero balances fold away. Receivers remain full, wrapped and labelled. Preview data is invalidated when inputs/accounts change and after successful deposits. +- `Flows.tsx`: a small royal-blue tag first in the Deposit and Redeem forms, always shown: "Redemptions are open 24/7. Deposit hours apply to deposits only." (white on royal blue, 15px, the first sentence bold; each sentence starts its own line when it wraps); decimal amount fields, exact Use full balance controls, search-to-add stocks, previews immediately after selected amounts, named approval steps and owed-first claim cards. Claim all precedes the cards; zero balances fold away. Receivers remain full, wrapped and labelled. Preview data is invalidated when inputs/accounts change and after successful deposits. - `Owner.tsx`: `ActionForm`, pairing cards, genesis sequence (`listGenesis`, `finalizeGenesis`), settings and pending proposal cards built from `pendingProposals` ids and `proposal(id)`, executable from two to nine days after creation. The Hours setting shows the current schedule in words and raw seconds and takes From and To as a weekday plus New York time (or "Always open"), previewing value, value2 and words; Dst shows its three rule words; FreshCount and FreshHours explain the market-holiday closure. Proposals send the `BaskVault.Action` struct. Forms use native details disclosures. Stock selectors begin on Choose a stock, retain an existing choice across reads and clear it when the stock disappears. Launch folds to one done line; Pause, Pending and Immediate precede proposal forms. Jump controls scroll and focus without a route change. Finalize stays disabled until the listing rows are pasted and every pasted row is listed with the same values. Consequential irreversible actions require an explicit checkbox; unauthorized controls remain visible and disabled. Financial buttons use system text rather than display lettering. All controls use native elements and visible labels. Focus has a 3px ink perimeter with a sunshine surround. Hash navigation focuses the page H1; a skip link reaches main content. Errors use alert regions, transaction progress uses a status region, and read failures offer Retry. Disabled buttons have nearby reasons and recovery controls. Primary hover/focus/active states explicitly keep white text on ink; disabled primary buttons use the same legible muted palette as other disabled controls. Action-local status retains transaction links and reports confirmed refreshes, proposal IDs/ready times and remaining owed amounts. Loading says Reading...; failure says unreadable with Retry. Balance and claim Retry controls appear only after failed reads; a complete, successful empty claim read says that nothing is owed. Incomplete stock reads say “Owed balances unreadable. Retry vault.” with Retry vault. Claim and loss result scopes include the token address so equal symbols cannot overwrite one another’s result. Motion is limited to the continuously running slow marquee, the Stock shelves labels' one-time restocking swing and 4px lift (all off when reduced motion is requested) and color-only control feedback. Buttons no longer scale text on press. Forced-colors focus uses the system Highlight color. diff --git a/README.md b/README.md index dedf6bf..766f00f 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,18 @@ What the checks allow in this update: - `check-interface.ts`: unit cases for the fixes and the date rounding. The fork runner's 24 screenshots are JPEG quality 36 (40 before): with the Docs FAQ and the longer Owner texts the quality-40 set no longer fits the 8 MiB delivery budget. So run `run-browser.mjs` before `check-bundle.mjs` (as in the list above): until the runner rewrites the 24 screenshots, the tree still holds the quality-40 set and the bundle reads over the limit. + +## Site update v10 + +- Docs: the deposits sentence starts with a capital letter: "Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open". +- Deposit and Redeem: a small royal-blue tag at the top of the form, shown whether deposits are open or closed: "Redemptions are open 24/7. Deposit hours apply to deposits only." The Redeem title now reads "Receive your share of every stock." The Vault and Deposit closed messages still end "Redemptions are always open." +- Stock shelves: no price time on the labels or in the details drawer (feeds post only on a 0.5% move or every 24 hours, so a time like "17h 55m ago" looked stale when it was not). The drawer row is "Feed price". Only a price the vault refuses as too old (reason 9) keeps its dimmed price and "old price" tag. `age()` left `model.ts` and its styles left `styles.css`. Display only: no read, check or transaction changed. +- Slogan strip: the two identical halves now share one grid cell and each moves as its own Web Animation (started in `Scenery.tsx`; the CSS `shop-scroll` animation is gone): from one half width right to one half width left, the second half half a cycle behind the first, so each jumps back only while it is wholly off screen and no `animationiteration` event wakes the page at a reset. Same words, lines, look and speed (exactly 16px/s, 64px/s at 660px and below); reduced motion cancels the motion and leaves a still, full strip. + +What the checks allow in this update: + +- `check-bundle.mjs`: in `web/src` only Docs, Flows, Vault, model and styles change (and Scenery for the slogan strip fix); `web/public`, `web/pinned`, the page shell and the build configuration are unchanged; no file is added. +- `check-calldata.mjs`: every other source is byte-identical to HEAD; Docs, Flows, Vault, model and Scenery equal HEAD once their listed v10 pieces are put back, so the tag words and the strip's Marquee hook are pinned. No call, action attribute, send or encoder changes: HEAD's and the new encoders give the same calldata, add no refusal and refuse with the same words. +- `check-art.mjs` (Chromium and WebKit, 12 widths): the tag is first in the Deposit and Redeem forms with the exact words, at least 14px, white on royal blue, inside its panel with whole words and clear of the character and her bubble; the Redeem title and Docs sentences; no price time on any label or in the drawer, and "old price" only for reason 9. `inspect-export.mjs` and `run-browser.mjs` check the tag (deposits open and closed) and the drawer too. +- Slogan strip: `check-art.mjs` (Chromium and WebKit, 23 widths 320-5,760px) and `check-motion.mjs` (three display scales) check that the halves are the only moving blocks, each k whole lines, each its own endless Web Animation at exactly the speed, with no CSS animation, `will-change` or 3D and no endless CSS animation on the page; that at loop phases just before, at and just after each half's reset the window is covered with the normal word gap at every join and at the seam; and that real playback across both resets fires no `animationiteration` event. `check-art.mjs`, `inspect-export.mjs`, `run-browser.mjs` and `check-visibility.mjs` check that reduced motion leaves no animation and a still, full strip. +- `check-visibility.mjs` (baseline: the v9 build): nothing may leave the first phone screen, and the slogan strip keeps its place and height. diff --git a/web/scripts/check-art.mjs b/web/scripts/check-art.mjs index e4ec09f..a42f8aa 100644 --- a/web/scripts/check-art.mjs +++ b/web/scripts/check-art.mjs @@ -37,6 +37,17 @@ const bundle = fs.readdirSync(path.join(root, "dist/assets")).filter(f => f.ends for (const line of Object.values(LINES)) assert.ok(bundle.includes(line), line); assert.ok(bundle.includes("A different receiver must connect here to claim any stocks still owed to it."), "receiver wording"); for (const gone of ["hehe", "WOW!", "Unsent stocks are owed", "must connect to claim", "vault-hero", "clerk-panel", "picture-stage"]) assert.ok(!bundle.includes(gone), "removed from the bundle: " + gone); +// v10: the 24/7 redemption tag and the capitalised Docs deposits sentence are in; the Stock shelves show no price time +// (the label age words and clock, the drawer's age and age() are gone from scripts and styles); the reason-9 +// "old price" tag stays. +const V10_TAG = "Redemptions are open 24/7. Deposit hours apply to deposits only."; +const V10_DOCS = "Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open"; +const styles = fs.readdirSync(path.join(root, "dist/assets")).filter(f => f.endsWith(".css")).map(f => fs.readFileSync(path.join(root, "dist/assets", f), "utf8")).join("\n"); +for (const text of ["Redemptions are open 24/7.", "Deposit hours apply to deposits only.", "Deposits open Sunday 8 pm to Friday 8 pm New York time", "old price", '"sa-old"', "Feed price"]) assert.ok(bundle.includes(text), "v10 text in the bundle: " + text); +for (const gone of ["Redemption is always open.", "deposits open Sunday", "Feed price / age", "just now", "Less than 1 min ago", " min ago", "m ago", "days ago", "price time: ", "Future timestamp", "sa-clock", "sa-upd", "sa-when"]) assert.ok(!(bundle + styles).includes(gone), "removed from the bundle (v10): " + gone); +assert.ok(styles.includes(".hours-tag"), "v10 tag style"); +// Words on the shelves that would be a price time. +const PRICE_TIME = /\bago\b|just now|price updated|price time|less than 1 min|future timestamp|\d+h \d+m/i; // v9 A4: the old tagline is gone from the whole built site (page, scripts, styles, icon), in any letter case. const TAGLINE = /one\s+basket/i; for (const f of fs.readdirSync(path.join(root, "dist"), {recursive:true}).map(String).filter(f => /\.(html|js|css|svg|json|txt)$/.test(f))) @@ -44,39 +55,82 @@ for (const f of fs.readdirSync(path.join(root, "dist"), {recursive:true}).map(St // The other pages keep their eyebrow (HEAD text); the Vault title has none. const EYEBROWS = {vault:null, deposit:"Fill your basket", redeem:"At the checkout", docs:"Know your basket", owner:"Behind the counter", losses:"Accounting health"}; // v9 A2 slogan strip: each half shows k whole slogan lines (one line is 720 px), k = the fewest lines that cover the -// viewport (1 up to 720 px, 2 up to 1440 px, ... 8 from 5041 px); 45 s per line (16 px/s), 11.25 s at 660 px and -// below (64 px/s); the moving block is at most two screens plus two lines wide and has no will-change. +// viewport (1 up to 720 px, 2 up to 1440 px, ... 8 from 5041 px). +// v10 strip loop: the two identical halves share one grid cell and each moves as its own Web Animation started by +// Marquee (Scenery.tsx; no CSS animation): linear and endless from translateX(100%) to translateX(-100%) of its own +// width, at exactly 16 px/s (64 px/s at 660 px and below; the cycle follows the measured half width L, so it lasts +// 2 L / speed), the second half half a cycle behind the first. So the halves always sit edge to edge and cover the +// window, each jumps back only while it is wholly off screen (wholly left of the window just before, wholly right of +// it just after), no animationiteration event fires at a reset, the only moving blocks are the two halves (k lines +// each, the track itself stays put) and nothing in the strip has will-change, a CSS animation or a 3D transform. +// Under reduced motion both animations are cancelled and the halves stand still side by side from the left edge. const SLOGAN = "Basket buddies! • Take a stroll down the aisles • Give your cart a twirl • "; const stripLines = width => Math.min(8, Math.max(1, Math.ceil(width / 720))); -// In the page: the strip's geometry, with the animation paused at `fraction` of its loop (null: leave it). `joins` are -// the gaps from the last glyph of each shown line to the first glyph of the next one (inside a half and across the -// seam between the halves), `wordGap` the normal gap between a bullet and the next word. +// Loop phases sampled, as fractions of a half's cycle: twelve evenly spaced ones plus just before, at and just after +// each half's reset (the first half jumps back at 0.5, the second at 0 = 1). +const STRIP_PHASES = [...new Set([0, 1e-7, ...Array.from({length: 12}, (_, i) => (i + 1) / 12), 0.4999999, 0.5000001, 0.9999999])].sort((a, b) => a - b); +// In the page: the strip's geometry, with both animations paused at `fraction` of their cycle (null: leave them). +// The halves are taken in screen order (left to right): `joins` are the gaps from the last glyph of each shown line to +// the first glyph of the next one (inside a half and across the seam between the halves), `wordGap` the normal gap +// between a bullet and the next word; `positions` are the halves' left edges (DOM order) from the window's left edge. function stripGeometry(fraction) { - const track = document.querySelector(".marquee-track"), win = track.parentElement, a = track.getAnimations(); - if (fraction !== null && a[0]) { a[0].pause(); a[0].currentTime = a[0].effect.getTiming().duration * fraction; } + const strip = document.querySelector(".marquee"), track = strip.querySelector(".marquee-track"), win = track.parentElement; + const dom = [...track.children], a = strip.getAnimations({subtree: true}); + const clock = a.map(x => ({state: x.playState, start: x.startTime, current: x.currentTime})); + if (fraction !== null) for (const x of a) { x.pause(); x.currentTime = x.effect.getTiming().duration * fraction; } const R = e => e.getBoundingClientRect(); const glyph = (span, i) => { const w = document.createTreeWalker(span, NodeFilter.SHOW_TEXT); for (let n, k = i; (n = w.nextNode()); k -= n.length) if (k < n.length) { const rg = document.createRange(); rg.setStart(n, k); rg.setEnd(n, k + 1); return rg.getBoundingClientRect(); } return null; }; - const halves = [...track.children].map(h => ({text: h.textContent, width: R(h).width, minWidth: getComputedStyle(h).minWidth, + const halves = dom.map(h => ({text: h.textContent, width: R(h).width, minWidth: getComputedStyle(h).minWidth, spans: [...h.children].map(s => ({text: s.textContent, display: getComputedStyle(s).display, width: R(s).width}))})); - const shown = [...track.children].map(h => [...h.children].filter(s => getComputedStyle(s).display !== "none")); + const order = dom.map((h, i) => i).sort((i, j) => R(dom[i]).left - R(dom[j]).left); + const shown = order.map(i => [...dom[i].children].filter(s => getComputedStyle(s).display !== "none")); const run = shown.flat(), text = run[0].textContent, last = text.trimEnd().length - 1, bullet = text.indexOf("•"); const wordGap = glyph(run[0], bullet + 2).left - glyph(run[0], bullet).right; const joins = run.slice(1).map((s, i) => glyph(s, 0).left - glyph(run[i], last).right); - const t = R(track), w = R(win), cs = getComputedStyle(track); - // Box edges, exact in both engines: the shown lines sit edge to edge from the track's left end to its right end, - // each line box as wide as its text (WebKit rounds single-glyph boxes to whole pixels, so glyph gaps get a looser bound). - const boxes = run.map(R), halfBoxes = [...track.children].map(R); - const edges = [boxes[0].left - t.left, t.right - boxes[boxes.length - 1].right, halfBoxes[0].left - t.left, halfBoxes[1].left - halfBoxes[0].right, t.right - halfBoxes[1].right, - ...boxes.slice(1).map((b, i) => b.left - boxes[i].right)]; + const t = R(track), w = R(win), hb = order.map(i => R(dom[i])); + // Box edges, exact in both engines: the right half starts where the left one ends, and in each half the shown lines + // sit edge to edge from its left end to its right end, each line box as wide as its text (WebKit rounds single-glyph + // boxes to whole pixels, so glyph gaps get a looser bound). + const boxes = shown.map(s => s.map(R)); + const edges = [hb[1].left - hb[0].right, ...boxes.flatMap((b, h) => [b[0].left - hb[h].left, hb[h].right - b[b.length - 1].right, ...b.slice(1).map((x, i) => x.left - b[i].right)])]; const textWidths = run.map(sp => { const rg = document.createRange(); rg.selectNodeContents(sp); return R(sp).width - rg.getBoundingClientRect().width; }); - return {halves, shownCounts: shown.map(s => s.length), track: t.width, frame: w.width, viewport: innerWidth, wordGap, joins, - lead: glyph(run[0], 0).left - t.left, tail: t.right - glyph(run[run.length - 1], last).right, edges, textWidths, - covers: t.left <= w.left + 0.01 && t.right >= w.right - 0.01, offset: t.left - w.left, running: a.filter(x => x.playState === "running").length, willChange: cs.willChange, animationName: cs.animationName, - transform: cs.transform, duration: a[0]?.effect.getTiming().duration, contain: getComputedStyle(win).contain, isolation: getComputedStyle(win).isolation}; + // Every element of the strip: no CSS animation, no will-change, no 3D. + const parts = [strip, win, track, ...dom, ...dom.flatMap(h => [...h.children])]; + const styles = parts.map(e => { const c = getComputedStyle(e); return {cls: e.className, animationName: c.animationName, willChange: c.willChange, transform: c.transform, transformStyle: c.transformStyle, perspective: c.perspective, backface: c.backfaceVisibility}; }); + const animations = a.map(x => { const tm = x.effect.getTiming(); return {target: dom.indexOf(x.effect.target), css: typeof CSSAnimation !== "undefined" && x instanceof CSSAnimation, + transition: typeof CSSTransition !== "undefined" && x instanceof CSSTransition, delay: tm.delay, duration: tm.duration, iterations: tm.iterations, easing: tm.easing, direction: tm.direction, + endDelay: tm.endDelay, iterationStart: tm.iterationStart, playbackRate: x.playbackRate, composite: x.effect.composite, + keyframes: x.effect.getKeyframes().map(k => [k.computedOffset, k.transform, k.easing, k.composite])}; }); + return {halves, order, shownCounts: shown.map(s => s.length), track: t.width, trackLeft: t.left - w.left, frame: w.width, viewport: innerWidth, wordGap, joins, + leads: shown.map((s, h) => glyph(s[0], 0).left - hb[h].left), tails: shown.map((s, h) => hb[h].right - glyph(s[s.length - 1], last).right), edges, textWidths, + positions: dom.map(h => R(h).left - w.left), gap: Math.max(0, hb[0].left - w.left, hb[1].left - hb[0].right, w.right - hb[1].right), + clock, animations, styles, endlessCss: document.getAnimations().filter(x => typeof CSSAnimation !== "undefined" && x instanceof CSSAnimation && x.effect.getTiming().iterations === Infinity).length, + contain: getComputedStyle(win).contain, isolation: getComputedStyle(win).isolation}; +} +// In the page: true once both halves run and their cycle matches the current half width (Marquee re-times the loop +// from a ResizeObserver when the viewport changes or the web font arrives); the exact values are asserted afterwards. +function stripSettled(speed) { + const h = document.querySelector(".marquee-half"), a = document.querySelector(".marquee").getAnimations({subtree: true}); + return a.length === 2 && a.every(x => x.playState === "running" && Math.abs(x.effect.getTiming().duration - 2 * h.getBoundingClientRect().width / speed * 1000) < 0.01); +} +// No animationiteration event can fire at a reset: that event only comes from CSS animations, and the strip has none +// (assertStripStyles below: no animation-name on any strip element, no endless CSS animation anywhere on the page); +// its loop is two Web Animations, which fire no iteration events. +// The v10 strip assertions shared by the running, paused and still states: no CSS animation, will-change or 3D on any +// strip element (a moving half may only be shifted along x), the window neither contained nor isolated. +function assertStripStyles(g, label) { + for (const s of g.styles) { + assert.equal(s.animationName, "none", `${label}: CSS animation on .${s.cls}`); + assert.equal(s.willChange, "auto", `${label}: will-change on .${s.cls}`); + assert.ok(s.transformStyle === "flat" && s.perspective === "none" && s.backface === "visible", `${label}: 3D setting on .${s.cls} ${JSON.stringify(s)}`); + assert.ok(s.transform === "none" || (s.cls === "marquee-half" && /^matrix\(1, 0, 0, 1, -?[\d.e+-]+, 0\)$/.test(s.transform)), `${label}: transform ${s.transform} on .${s.cls}`); + } + assert.equal(g.contain, "none"); assert.equal(g.isolation, "auto"); + assert.equal(g.endlessCss, 0, `${label}: an endless CSS animation on the page`); } const server = http.createServer((req,res) => { const parts = req.url.split("/"); @@ -222,7 +276,12 @@ try { await page.waitForFunction(routeShown,"deposit"); await page.evaluate(()=>document.fonts.ready); assert.equal(await page.locator('.marquee button').count(),0); - const k=stripLines(width),per=width<=660?11250:45000,label=`${engine} strip ${width}`; + const k=stripLines(width),per=width<=660?11250:45000,speed=width<=660?64:16,label=`${engine} strip ${width}`; + // v10: Marquee re-times the loop after a viewport change or the web font's arrival (ResizeObserver): wait for the + // strip's font itself (document.fonts.ready can resolve before WebKit starts loading it after a reload), then give + // the loop up to 3 s to settle; the exact cycle and speed are asserted below either way. + await page.evaluate(()=>document.fonts.load('24px Pixel').then(()=>document.fonts.ready)); + await page.waitForFunction(stripSettled,speed,{timeout:3000}).catch(()=>{}); const g=await page.evaluate(stripGeometry,null); assert.equal(g.viewport,width,label); assert.equal(g.halves.length,2,label+': two halves'); @@ -238,39 +297,73 @@ try { assert.ok(Math.abs(g.halves[0].width-k*720)<0.5*k,label+': half = k lines, not '+g.halves[0].width); assert.ok(k*720>=width&&g.halves[0].width>=g.frame-0.01,label+': k lines cover the viewport'); if(k>1&&width<=5760) assert.ok((k-1)*7200,label+': word gap'); - // Line boxes edge to edge (no margin, padding or stretch anywhere in the track), each as wide as its text. - for(const e of s.edges) assert.ok(Math.abs(e)<0.01,label+': line boxes not edge to edge '+JSON.stringify(s.edges)); - for(const e of s.textWidths) assert.ok(Math.abs(e)<1,label+': a line box wider than its text '+JSON.stringify(s.textWidths)); + const L=g.halves[0].width,D=g.animations[0]?.duration; + // v10: the track stays put (one grid cell, one half wide); the only moving blocks are the two halves, each k lines + // (at most the viewport plus one line) wide, each with its own Web Animation, both on one clock. + assert.ok(Math.abs(g.track-L)<0.01&&Math.abs(g.trackLeft)<0.01,label+': the track is one half wide and stays at the left edge '+[g.track,g.trackLeft]); + assert.ok(L<=width+720+0.5*k,label+': moving block '+L+' px is over the viewport plus one line'); + assert.deepEqual(g.animations.map(x=>x.target).sort(),[0,1],label+': one animation on each half and none elsewhere in the strip '+JSON.stringify(g.animations.map(x=>x.target))); + assertStripStyles(g,label); + for(const x of g.animations) { + assert.equal(x.css,false,label+': the loop is a Web Animation, not a CSS animation'); + assert.equal(x.transition,false,label+': the loop is not a transition'); + assert.equal(x.iterations,Infinity,label+': endless'); + assert.ok(x.easing==='linear'&&x.direction==='normal'&&x.endDelay===0&&x.iterationStart===0&&x.playbackRate===1&&x.composite==='replace',label+': linear, forward, unscaled '+JSON.stringify(x)); + assert.deepEqual(x.keyframes,[[0,'translateX(100%)','linear','auto'],[1,'translateX(-100%)','linear','auto']],label+': keyframes '+JSON.stringify(x.keyframes)); + // Exact speed: two half widths per cycle at 16 px/s (64 px/s at 660 px and below), to a millionth of a px/s. + assert.ok(Math.abs(2*L/(x.duration/1000)-speed)<1e-6,label+': speed '+2*L/(x.duration/1000)+' px/s, not '+speed); + assert.ok(Math.abs(x.duration-2*k*per)<=k*per/720+0.5,label+': cycle '+x.duration+' ms is not two k-line halves'); + } + assert.ok(g.clock.every(c=>c.state==='running')&&Math.abs(g.clock[0].start-g.clock[1].start)<0.001&&g.animations[0].duration===g.animations[1].duration,label+': both halves run on one clock '+JSON.stringify(g.clock)); + const tol=engine==='WebKit'?2.5:0.5; + for (const fraction of STRIP_PHASES) { + const s=await page.evaluate(stripGeometry,fraction),at=`${label} at ${fraction}`; + // Covered: the halves sit edge to edge across the whole window, also just before, at and just after a reset. + assert.ok(s.gap<0.01,at+': blank stretch of '+s.gap+' px in the strip window '+JSON.stringify(s.positions)); + // Where each half is: each runs from +L to -L, the first half half a cycle ahead of the second (exactly at a + // half's own reset either end is accepted: both are off screen). + s.positions.forEach((p,i)=>{const q=(fraction+(i===0?0.5:0))%1,exp=L-2*L*q; + assert.ok(Math.abs(p-exp)<0.05||(q===0&&Math.abs(p+L)<0.05),at+`: half ${i+1} at ${p} px, not ${exp}`);}); + // Each half jumps back only while wholly off screen: just before its reset it lies wholly left of the window, + // just after it wholly right of it. + for(const [i,r] of [[0,0.5],[1,1]]) { + if(Math.abs(fraction-(r-1e-7))<1e-12) assert.ok(s.positions[i]+L<=0.05,at+`: half ${i+1} still on screen just before its reset (right edge ${s.positions[i]+L})`); + if(Math.abs(fraction-(r+1e-7)%1)<1e-12) assert.ok(s.positions[i]>=s.frame-0.05,at+`: half ${i+1} not wholly right of the window just after its reset (left edge ${s.positions[i]})`); + } + assert.ok(s.wordGap>0,at+': word gap'); + // Line boxes and halves edge to edge (no margin, padding or stretch anywhere), each line box as wide as its text. + for(const e of s.edges) assert.ok(Math.abs(e)<0.01,at+': line boxes or halves not edge to edge '+JSON.stringify(s.edges)); + for(const e of s.textWidths) assert.ok(Math.abs(e)<1,at+': a line box wider than its text '+JSON.stringify(s.textWidths)); // Glyphs: WebKit reports single-glyph boxes rounded out to whole pixels (each edge up to 1 px off), hence 2.5 px // there; a blank stretch would be a whole line (720 px) or more, and the box edges above are exact. - const tol=engine==='WebKit'?2.5:0.5; - assert.ok(Math.abs(s.lead)new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))); const still=await page.evaluate(stripGeometry,null); - assert.equal(still.animationName,"none",label+': reduced motion'); - assert.ok(Math.abs(still.offset)<0.01&&still.running===0&&['none','matrix(1, 0, 0, 1, 0, 0)'].includes(still.transform),label+': reduced motion still '+JSON.stringify([still.offset,still.running,still.transform])); - assert.equal(still.covers,true,label+': still strip full'); + await page.waitForTimeout(150); + const still2=await page.evaluate(stripGeometry,null); + // v10 reduced motion: both animations cancelled (not only paused), nothing moves, the halves side by side from the + // window's left edge, the strip full. + assert.equal(still.animations.length,0,label+': reduced motion: the loop is cancelled, not only paused'); + assertStripStyles(still,label+' reduced motion'); + assert.deepEqual(still2.positions,still.positions,label+': reduced motion: the strip moved'); + const side=[...still.positions].sort((x,y)=>x-y); + assert.ok(Math.abs(side[0])<0.01&&Math.abs(side[1]-L)<0.01,label+': reduced motion: halves not side by side from the left edge '+JSON.stringify(still.positions)); + assert.ok(still.gap<0.01,label+': still strip full'); assert.deepEqual(still.shownCounts,[k,k]); await page.emulateMedia({reducedMotion:"no-preference"}); - strips.push(`${width}:${k}/${Math.round(g.track)}/${g.duration / 1000}s`); + await page.waitForFunction(stripSettled,speed,{timeout:3000}).catch(()=>{}); + const again=await page.evaluate(stripGeometry,null); + assert.ok(again.animations.length===2&&again.clock.every(c=>c.state==='running')&&again.animations.every(x=>Math.abs(2*L/(x.duration/1000)-speed)<1e-6)&&again.gap<0.01,label+': motion back when reduced motion is turned off '+JSON.stringify([again.clock,again.gap])); + strips.push(`${width}:${k}/${Math.round(L)}/${D / 1000}s`); } - checks.push(`${engine} slogan strip, width:lines per half/track px/loop [${strips.join(' ')}]: eight lines in the markup of each half, the first k shown (k = the fewest 720 px lines covering the viewport), identical halves, track <= 2 x (viewport + 720), no will-change, 16 px/s (64 px/s at 660 px and below); at 8 loop positions the window is covered, line boxes sit edge to edge and every line join and the seam between the halves has the normal word gap; reduced motion still and full`); + checks.push(`${engine} slogan strip, width:lines per half/half px/cycle [${strips.join(' ')}]: eight lines in the markup of each half, the first k shown (k = the fewest 720 px lines covering the viewport), identical halves; v10 loop: the track stays put and the only moving blocks are the two halves (k lines each, at most the viewport plus one line), each its own endless linear Web Animation translateX(100%) to translateX(-100%) on one shared clock, no CSS animation, will-change or 3D in the strip and no endless CSS animation on the page; speed exactly 16 px/s (64 px/s at 660 px and below), two half widths per cycle; at ${STRIP_PHASES.length} loop phases (just before, at and just after each half's reset among them) the halves sit edge to edge across the window at their expected places, each jumping back only while wholly off screen, line boxes edge to edge and every line join and the seam with the normal word gap; so no animationiteration event can fire (that event comes only from CSS animations); reduced motion: no animation (cancelled), a still, full strip from the left edge; motion back when it is turned off`); await page.emulateMedia({reducedMotion:"reduce"}); for(const width of [1440,1000,999,960,800,720,661,660,375,360,359,320]) { await page.setViewportSize({width,height:width===375?812:1000}); @@ -299,11 +392,12 @@ try { await page.waitForFunction(()=>!document.querySelector('button.refresh')?.disabled); const sh=await page.evaluate(()=>{ const sec=document.querySelectorAll('.stock-section'),s=sec[0],R=e=>e.getBoundingClientRect(),unit=s?.querySelector(':scope .sa-unit'); - const labels=[...s.querySelectorAll('article.sa-label')].map(l=>({ticker:l.querySelector('h4')?.textContent??'',price:!!l.querySelector('.sa-price'),status:l.querySelector('.sa-st')?.textContent??'',more:l.querySelector('button.sa-more')?.getAttribute('aria-expanded'),left:R(l).left,right:R(l).right,height:R(l).height,inSlot:!!l.parentElement?.matches('li.sa-slot')&&!!l.closest('ul.sa-shelves[role="list"]')})); + const labels=[...s.querySelectorAll('article.sa-label')].map(l=>({ticker:l.querySelector('h4')?.textContent??'',price:!!l.querySelector('.sa-price'),status:l.querySelector('.sa-st')?.textContent??'',more:l.querySelector('button.sa-more')?.getAttribute('aria-expanded'),left:R(l).left,right:R(l).right,height:R(l).height,inSlot:!!l.parentElement?.matches('li.sa-slot')&&!!l.closest('ul.sa-shelves[role="list"]'), + text:l.textContent,ageLines:[...l.querySelectorAll('.sa-age')].map(e=>e.textContent),dimmed:!!l.querySelector('.sa-price.is-stale'),timeParts:l.querySelectorAll('.sa-clock, .sa-upd, .sa-when, small').length,reason9:(l.querySelector('.sa-chip')?.textContent??'').includes('feed price missing or too old')})); const all=s.querySelector('.sa-filters button'); return {sections:sec.length,heading:s.querySelector(':scope > .section-heading h2')?.textContent,old:document.querySelectorAll('.stock-grid, .stock-card').length,unit:unit?{left:R(unit).left,right:R(unit).right}:null,labels, all:all?Number(all.querySelector('.sa-count')?.textContent):null,allName:all?.firstChild?.textContent?.trim(),controls:[...s.querySelectorAll('.sa-filters button, .sa-search input')].map(e=>R(e).height), - moving:document.getAnimations().filter(a=>a.effect?.target?.closest?.('.stock-section')).length}; + moving:document.getAnimations().filter(a=>a.effect?.target?.closest?.('.stock-section')).length,sectionText:s.textContent}; }); assert.equal(sh.sections,1,`${engine} vault ${width}: one Stock shelves panel`); assert.equal(sh.heading,'Stock shelves'); @@ -317,6 +411,24 @@ try { assert.ok(l.ticker.length>0&&l.price&&/^(Open|Closed|Retired)/.test(l.status)&&l.more==='false'&&l.inSlot,`${engine} vault ${width}: label ${JSON.stringify(l)}`); assert.ok(l.left>=sh.unit.left-0.5&&l.right<=sh.unit.right+0.5,`${engine} vault ${width}: label ${l.ticker} outside the shelf unit`); assert.ok(l.height>=44,`${engine} vault ${width}: label ${l.ticker} tap height ${l.height}`); + // v10: no price time on any label; only a price refused as too old (reason 9) keeps the "old price" tag + // (and is dimmed when it is readable), with no time beside it. + assert.doesNotMatch(l.text,PRICE_TIME,`${engine} vault ${width}: price time on label ${l.ticker}`); + assert.equal(l.timeParts,0,`${engine} vault ${width}: label ${l.ticker} has a clock or time part`); + assert.deepEqual(l.ageLines,l.reason9?['old price']:[],`${engine} vault ${width}: label ${l.ticker} old price tag`); + if(l.dimmed) assert.equal(l.reason9,true,`${engine} vault ${width}: label ${l.ticker} dimmed without reason 9`); + } + assert.doesNotMatch(sh.sectionText,PRICE_TIME,`${engine} vault ${width}: price time on the Stock shelves`); + // v10: the details drawer names the row "Feed price" and shows the price only (no age). + if([1440,375].includes(width)&&sh.labels.length) { + await page.locator('.stock-section article.sa-label button.sa-more').first().click(); + const d=await page.locator('#sa-drawer').evaluate(e=>{const row=[...e.querySelectorAll('dl > div')].find(x=>/^Feed price/.test(x.querySelector('dt')?.textContent??''));return {dt:row?.querySelector('dt').textContent,dd:row?.querySelector('dd').textContent,small:row?row.querySelectorAll('small').length:-1,text:e.textContent};}); + assert.equal(d.dt,'Feed price',`${engine} vault ${width}: drawer row name`); + assert.match(d.dd,/^(\$[\d,]+(\.\d+)?|unreadable \/ invalid feed)$/,`${engine} vault ${width}: drawer price only (${d.dd})`); + assert.equal(d.small,0,`${engine} vault ${width}: drawer age`); + assert.doesNotMatch(d.text,PRICE_TIME,`${engine} vault ${width}: price time in the drawer`); + await page.locator('#sa-drawer button',{hasText:'Close'}).click(); + await page.waitForFunction(()=>!document.querySelector('#sa-drawer')); } assert.equal(sh.controls.length,5); for(const h of sh.controls) assert.ok(h>=44,`${engine} vault ${width}: filter/search height ${h}`); @@ -365,12 +477,42 @@ try { assert.equal(await page.locator('.page-title.has-character, .title-character, .character-picture, .speech-bubble').count(),0,`${engine} ${name} no character`); } if(name==='redeem') assert.equal(await page.getByText(/Unsent stocks are owed/).count(),0,`${engine} redeem receiver note`); + // v10: the 24/7 redemption tag is the first thing in the Deposit and Redeem forms (shown open or closed): exact + // words, at least 14 px, white on royal blue, inside its panel with whole words, clear of the title character + // and her bubble; on no other page. The Redeem title sentence no longer says it. + const tag=await page.evaluate(()=>{ + const ts=[...document.querySelectorAll('.hours-tag')],t=ts[0],title=document.querySelector('.page-title > p:last-of-type')?.textContent.replace(/\s+/g,' ').trim(); + if(!t) return {count:0,title}; + const R=e=>e.getBoundingClientRect(),r=R(t),panel=t.parentElement,pr=R(panel),cs=getComputedStyle(t),pcs=getComputedStyle(panel),split=[]; + const w=document.createTreeWalker(t,NodeFilter.SHOW_TEXT); + for(let n;(n=w.nextNode());) for(const m of n.textContent.matchAll(/\S+/g)){ + const rg=document.createRange();rg.setStart(n,m.index);rg.setEnd(n,m.index+m[0].length); + if(new Set([...rg.getClientRects()].filter(x=>x.width>0).map(x=>Math.round(x.top))).size!==1) split.push(m[0]); + const wr=rg.getBoundingClientRect();if(wr.leftr.right) split.push('outside: '+m[0]); + } + const hit=(a,b)=>a.left section.panel'),font:parseFloat(cs.fontSize),color:cs.color,background:cs.backgroundColor, + inside:r.left>=pr.left+parseFloat(pcs.borderLeftWidth)-0.5&&r.right<=pr.right-parseFloat(pcs.borderRightWidth)+0.5,split,overlap:[...document.querySelectorAll('.title-character img, .speech-bubble')].filter(e=>hit(R(e),r)).length}; + }); + if(['deposit','redeem'].includes(name)) { + assert.equal(tag.count,1,`${engine} ${name} ${width}: one 24/7 tag`); + assert.equal(tag.text,V10_TAG,`${engine} ${name} ${width}: tag words`); + assert.equal(tag.first&&tag.inForm,true,`${engine} ${name} ${width}: tag first in the form panel`); + assert.ok(tag.font>=14,`${engine} ${name} ${width}: tag text ${tag.font} px`); + assert.deepEqual([tag.color,tag.background],['rgb(255, 255, 255)','rgb(23, 75, 193)'],`${engine} ${name} ${width}: tag colours`); + assert.equal(tag.inside,true,`${engine} ${name} ${width}: tag outside its panel`); + assert.deepEqual(tag.split,[],`${engine} ${name} ${width}: tag words split or outside`); + assert.equal(tag.overlap,0,`${engine} ${name} ${width}: tag under the character or her bubble`); + } else assert.equal(tag.count,0,`${engine} ${name}: 24/7 tag`); + if(name==='redeem') assert.equal(tag.title,'Receive your share of every stock.',`${engine} redeem ${width}: title sentence`); if(name==='docs') { const faq=await page.locator('.docs-grid > .panel').evaluateAll(ps=>{const i=ps.findIndex(p=>p.querySelector('h2')?.textContent==='Redemption'),f=ps[i+1];return {heading:f?.querySelector(':scope > h2')?.textContent,question:f?.querySelector(':scope > h3')?.textContent,answer:f?.querySelector(':scope > p')?.textContent.replace(/\s+/g,' ').trim()};}); + const deposits=await page.locator('.docs-grid > .panel').evaluateAll(ps=>ps.find(p=>p.querySelector('h2')?.textContent==='Deposits')?.querySelector(':scope > p')?.textContent.replace(/\s+/g,' ').trim()); + assert.equal(deposits,V10_DOCS,`${engine} docs ${width}: deposits sentence (v10)`); assert.deepEqual(faq,{heading:'FAQ',question:'What if a stock can’t be sent when I redeem?',answer:'Rarely, a stock can’t be sent at that moment (for example its issuer has paused transfers). The vault then keeps it for the receiver, who collects it later with Claim on the Redeem page. Only the receiver wallet can claim, so redeem to a wallet you control, not an exchange deposit address.'}); } } - checks.push(`${engine} ${width}: six routes without overflow; both shelf rows filled with ordered square images (the Stock shelves unit on Vault shows its first row only, by design); Stock shelves: one shelf-edge label per listed stock (${shelfCounts.at(-1)?.split(':')[1]}) with ticker, price, status and Details, inside the shelf unit, labels/filters/search at least 44 px, no old stock cards, still under reduced motion; title character inside the title panel with exact speech and picture height, head above the panel, bubble clear of the picture, tail clear of face/hands/items, whole bubble words, strip clear of the text, wallet hint gap; no old frames or WOW!, Fresh! kept; Docs FAQ; no Redeem receiver note; no tagline on the page, Vault title without eyebrow (text centred beside her, or 17 px under her on phones), other eyebrows kept, footer icon/name/links`); + checks.push(`${engine} ${width}: six routes without overflow; both shelf rows filled with ordered square images (the Stock shelves unit on Vault shows its first row only, by design); Stock shelves: one shelf-edge label per listed stock (${shelfCounts.at(-1)?.split(':')[1]}) with ticker, price, status and Details, inside the shelf unit, labels/filters/search at least 44 px, no old stock cards, still under reduced motion; title character inside the title panel with exact speech and picture height, head above the panel, bubble clear of the picture, tail clear of face/hands/items, whole bubble words, strip clear of the text, wallet hint gap; no old frames or WOW!, Fresh! kept; Docs FAQ; no Redeem receiver note; v10: the 24/7 redemption tag first in the Deposit and Redeem forms (exact words, at least 14 px, white on royal blue, inside its panel, whole words, clear of the character and her bubble), the Redeem title without it, the capitalised Docs deposits sentence, no price time on any Stock shelves label or in the drawer (reason 9 keeps only its "old price" tag); no tagline on the page, Vault title without eyebrow (text centred beside her, or 17 px under her on phones), other eyebrows kept, footer icon/name/links`); } // Every width from 320 to 2560 px: whole bubble words inside the bubble, the strip clear of the title text, the bubble in // its frame and off the picture's face/hands/item boxes, no overflow and a wallet hint gap of at least 8 px. diff --git a/web/scripts/check-bundle.mjs b/web/scripts/check-bundle.mjs index f90be21..23857de 100644 --- a/web/scripts/check-bundle.mjs +++ b/web/scripts/check-bundle.mjs @@ -6,14 +6,12 @@ import {createHash} from 'node:crypto'; const root=process.env.BASKET_SOURCE_ROOT||path.resolve('..'); const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'}); const changed=git(['diff','--name-only','HEAD']).trim().split('\n').filter(Boolean); -// v9 (look update, low fixes F1-F16, slogan strip, favicon, tagline removal, Stock shelves labels in Vault.tsx and -// styles.css): only these web/src files may change; -// Losses, poolMath, deployment and the ABI stay HEAD-frozen and main/Scenery may only take their one documented edit -// (both byte-checked by check-calldata.mjs); web/pinned stays unchanged; in web/public only favicon.svg may change, -// and only to the chosen icon (art files unchanged); no file is added to web/src or web/public. -const lookSource=['components.tsx','Vault.tsx','Flows.tsx','Docs.tsx','styles.css','main.tsx','Scenery.tsx'].map(f=>'web/src/'+f); -const fixSource=['chain.ts','model.ts','newYork.ts','governance.ts','Owner.tsx','wallet.tsx'].map(f=>'web/src/'+f); -const frozenSource=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json'].map(f=>'web/src/'+f); +// v10 (on top of v9 = HEAD: the Docs deposits sentence, the 24/7 redemption tag on Deposit and Redeem, no price +// times on the Stock shelves, and the slogan strip fix in Scenery.tsx and styles.css): only these web/src files may +// change; every other source stays as at HEAD and the changed ones may only take their documented pieces (both +// byte-checked by check-calldata.mjs); web/pinned and web/public stay unchanged (the favicon is still the chosen icon); +// no file is added to web/src or web/public. +const v10Source=['Docs.tsx','Flows.tsx','Vault.tsx','model.ts','styles.css','Scenery.tsx'].map(f=>'web/src/'+f); // The new favicon: red panel, yellow frame, the header's basket in yellow. const FAVICON_SHA256='0a31ead9064a33cbc12abe6388cf3f0a29888d3a183819e02116e56dd8ba2238'; const sha256=p=>createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex'); @@ -21,11 +19,11 @@ assert.equal(sha256('web/public/favicon.svg'),FAVICON_SHA256,'web/public/favicon assert.equal(sha256('dist/favicon.svg'),FAVICON_SHA256,'dist/favicon.svg is the chosen icon'); for(const p of changed){ assert.ok(!p.startsWith('web/pinned/'),'Pinned source changed: '+p); - assert.ok(!p.startsWith('web/public/')||p==='web/public/favicon.svg','Public file changed: '+p); + assert.ok(!p.startsWith('web/public/'),'Public file changed: '+p); // The page shell and build configuration stay as at HEAD (no script can be added outside web/src); in web/validation only the README text changes. assert.ok(!['web/index.html','web/vite.config.ts','web/tsconfig.json'].includes(p)&&(!p.startsWith('web/validation/')||p==='web/validation/README.md'),'Page shell, build configuration or validation fixture changed: '+p); assert.ok(!/(^|\/)(lib|node_modules|\.github|\.git)(\/|$)|(^|\/)\.env(?:\.|$)|(^|\/)(foundry\.toml|foundry\.lock|remappings\.txt|\.gitmodules|package\.json|[^/]*lock[^/]*)$/.test(p),'Protected path changed: '+p); - if(p.startsWith('web/src/'))assert.ok(!frozenSource.includes(p)&&(lookSource.includes(p)||fixSource.includes(p)),'Source outside the look update and the low fixes changed: '+p); + if(p.startsWith('web/src/'))assert.ok(v10Source.includes(p),'Source outside the v10 scope changed: '+p); assert.ok(!p.startsWith('src/'),'Root contract source changed'); } assert.deepEqual(git(['ls-files','--others','--exclude-standard','--','web/src','web/public']).trim().split('\n').filter(Boolean),[],'New files in web/src or web/public'); @@ -35,7 +33,7 @@ for(const p of files) assert.ok(!/(^|\/)(node_modules|\.npm|\.cache|\.vite|\.pla const shots=files.filter(p=>/^artifacts\/(empty|stocks25)-.*\.jpg$/.test(p));assert.equal(shots.length,24); const extraImages=files.filter(p=>/^artifacts\/.*\.(png|jpg|webp|svg)$/.test(p)&&!shots.includes(p)&&!/^artifacts\/redeem-title-dpr-(1|1\.25|1\.5)\.png$/.test(p));assert.deepEqual(extraImages,[]); const productionFiles={};for(const p of files.filter(p=>p.startsWith('dist/'))){const b=fs.readFileSync(path.join(root,p));productionFiles[p]={bytes:b.length,sha256:createHash('sha256').update(b).digest('hex')};} -const result={limitBytes:8388608,uncompressedDeliveryBytes:0,fileCount:files.length,productionExportBytes:Object.values(productionFiles).reduce((a,b)=>a+b.bytes,0),productionFiles,accounting:'Complete candidate file contents including tracked lib sources and this report. Excludes Git metadata, removed task inputs and disposable test/scratch. No Git metadata was modified.',checks:['Protected configuration, dependencies, root contracts, web/pinned, web/index.html, web/vite.config.ts, web/tsconfig.json and the validation fixtures unchanged; web/public unchanged except favicon.svg, which (and dist/favicon.svg) has the SHA-256 of the chosen icon; web/src changed only within the v9 scope (look: components, Vault (with the Stock shelves labels), Flows, Docs, styles, main (footer tagline removed), Scenery (slogan strip); low fixes: chain, model, newYork, governance, Owner, wallet; Losses, poolMath, deployment and the ABI frozen)','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 36 since v9, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']}; +const result={limitBytes:8388608,uncompressedDeliveryBytes:0,fileCount:files.length,productionExportBytes:Object.values(productionFiles).reduce((a,b)=>a+b.bytes,0),productionFiles,accounting:'Complete candidate file contents including tracked lib sources and this report. Excludes Git metadata, removed task inputs and disposable test/scratch. No Git metadata was modified.',checks:['Protected configuration, dependencies, root contracts, web/pinned, web/index.html, web/vite.config.ts, web/tsconfig.json and the validation fixtures unchanged; web/public unchanged (web/public/favicon.svg and dist/favicon.svg keep the SHA-256 of the chosen icon); web/src changed only within the v10 scope (Docs: deposits sentence; Flows: 24/7 redemption tag; Vault and model: no price times on the Stock shelves; styles: the tag and the removed age rules; Scenery and styles: slogan strip fix); every other source frozen','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 36 since v9, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']}; const report=path.join(root,'artifacts/bundle-check.json'); for(let i=0;i<4;i++){fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');result.uncompressedDeliveryBytes=files.reduce((sum,p)=>sum+fs.statSync(path.join(root,p)).size,0);} fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');assert.ok(result.uncompressedDeliveryBytes<=result.limitBytes,"Over the 8 MiB budget (if the 24 screenshots are still the quality-40 set, run scripts/run-browser.mjs first: it rewrites them at quality 36): "+JSON.stringify(result));console.log('PASS complete bundle',result.uncompressedDeliveryBytes,'/',result.limitBytes,'bytes; lib included'); diff --git a/web/scripts/check-calldata.mjs b/web/scripts/check-calldata.mjs index 6a227f0..a541f23 100644 --- a/web/scripts/check-calldata.mjs +++ b/web/scripts/check-calldata.mjs @@ -9,16 +9,56 @@ const root=process.env.BASKET_SOURCE_ROOT||path.resolve('..'); const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'}); const baseline=p=>git(['show','HEAD:'+p]); const read=p=>fs.readFileSync(path.join(root,p),'utf8'); -// v9 (look update + low fixes F1-F16). Byte-identical to HEAD: the pages left alone and everything that defines -// the vault address, its code hash, its ABI and the pool arithmetic. -const protectedFiles=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json']; +// v10 (on top of v9, which is HEAD): the Docs deposits sentence starts with a capital letter; Deposit and Redeem show +// an always-visible "Redemptions are open 24/7. Deposit hours apply to deposits only." tag (the Redeem title drops +// "Redemption is always open."); the Stock shelves show no price time on the labels or in the drawer (only a reason-9 +// price keeps its "old price" tag), so age() leaves model.ts; the slogan strip's two halves each move as their own Web +// Animation started by Marquee (Scenery.tsx). Every other web/src source is byte-identical to HEAD. +const protectedFiles=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json','main.tsx','components.tsx','Owner.tsx','wallet.tsx','governance.ts','chain.ts','newYork.ts']; for(const f of protectedFiles) assert.equal(read('web/src/'+f),baseline('web/src/'+f),f); -// main.tsx and Scenery.tsx: byte-identical to HEAD once the one documented edit is put back (each piece exactly once): -// A4 the footer tagline removed; A2 eight slogan lines per strip half instead of four. -const TAGLINE=['Stock','Tokens.','One','basket.'].join(' '); +// The v10 files: byte-identical to HEAD once their documented pieces [v10, HEAD] are put back (each exactly once), so +// every word, call and attribute in them is pinned, the 24/7 tag text included. Scenery.tsx: the slogan strip fix, two +// pieces (the react import with the whole Marquee hook that animates the halves, and the track's ref), so the hook +// text is pinned too (check-art.mjs and check-motion.mjs test what it does in the browser). const editedFiles={ - 'main.tsx':[['Basket Protocol\n ','Basket Protocol\n '+TAGLINE+'\n ']], - 'Scenery.tsx':[['{Array.from({ length: 8 }, (_, i) => (','{Array.from({ length: 4 }, (_, i) => (']], + "Docs.tsx":[ + ["

\n Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US\n market holidays, redemptions always open\n", + "

\n deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US\n market holidays, redemptions always open\n"] + ], + "Flows.tsx":[ + ["type Props = { snapshot: Snapshot; wallet: Wallet };\n// Always shown at the top of the Deposit and Redeem forms (open or closed), so\n// nobody reads the deposit hours as redemption hours.\nfunction HoursTag() {\n return (\n

\n Redemptions are open 24/7.{\" \"}\n Deposit hours apply to deposits only.\n

\n );\n}\nexport function DepositPage({ snapshot: s, wallet: w }: Props) {\n", + "type Props = { snapshot: Snapshot; wallet: Wallet };\nexport function DepositPage({ snapshot: s, wallet: w }: Props) {\n"], + ["
\n \n \n", + "
\n \n"], + [" >\n Receive your share of every stock.\n \n", + " >\n Receive your share of every stock. Redemption is always open.\n \n"], + ["
\n \n

Redeem shares

\n", + "
\n

Redeem shares

\n"] + ], + "Vault.tsx":[ + [" usd,\n pairingMatches,\n", + " usd,\n age,\n pairingMatches,\n"], + ["// cents in two digits (\"$669.5\" shows as $669 and 50 cents).\nfunction ShelfPrice({ a, stale }: { a: Asset; stale: boolean }) {\n", + "// cents in two digits (\"$669.5\" shows as $669 and 50 cents).\n// age() as today, but on the labels under 1 minute reads \"just now\" (the long\n// words wrapped and made the row taller; the drawer keeps age()'s full words)\n// and 48 hours or more reads in days (\"4 days ago\")\nfunction shelfAge(a: Asset): string {\n const t = age(a.updatedAt);\n if (!t.endsWith(\" ago\") || a.updatedAt === undefined) return t;\n const s = Math.floor(Date.now() / 1000 - Number(a.updatedAt));\n return s < 60 ? \"just now\" : s >= 2 * 86400 ? `${Math.floor(s / 86400)} days ago` : t;\n}\nfunction ShelfPrice({ a, stale }: { a: Asset; stale: boolean }) {\n"], + ["}\n// No price time on the labels or in the drawer: feeds post only on a 0.5% move\n// or every 24 hours, so an age like \"17h 55m ago\" looked stale when it was not.\n// Only a price the vault refuses as too old (reason 9) gets the \"old price\" tag.\nfunction OldPriceTag({ stale }: { stale: boolean }) {\n return stale ? (\n

\n old price\n

\n ) : null;\n}\n", + "}\nfunction PriceAge({ a, stale }: { a: Asset; stale: boolean }) {\n const t = shelfAge(a);\n return (\n

\n {stale && old price}\n {stale && \" \"}\n {t.endsWith(\" ago\") || t === \"just now\" ? (\n <>\n {/* narrower labels show a small clock (or nothing) instead of the words; still read out */}\n price updated \n \n \n {t.charAt(0).toLowerCase() + t.slice(1)}\n \n \n ) : (\n `price time: ${t}`\n )}\n

\n );\n}\n"], + [" \n \n \n", + " \n \n \n"], + ["
\n
Feed price
\n
\n", + "
\n
Feed price / age
\n
\n"], + [" ? \"unreadable / invalid feed\"\n : \"$\" + fmt(a.answer, a.feedDecimals)}\n
\n", + " ? \"unreadable / invalid feed\"\n : \"$\" + fmt(a.answer, a.feedDecimals)}{\" \"}\n {age(a.updatedAt)}\n \n"] + ], + "model.ts":[ + ["}\nexport const same = (a?: string, b?: string) =>\n", + "}\nexport function age(n: bigint | undefined, now = Date.now() / 1000) {\n if (n === undefined || n === 0n) return \"unreadable\";\n const s = Math.floor(now - Number(n));\n return s < 0\n ? \"Future timestamp\"\n : s < 60\n ? \"Less than 1 min ago\"\n : s < 3600\n ? `${Math.floor(s / 60)} min ago`\n : `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m ago`;\n}\nexport const same = (a?: string, b?: string) =>\n"] + ], + "Scenery.tsx":[ + ["import { useLayoutEffect, useRef } from \"react\";\n\nexport function Marquee() {\n const trackRef = useRef(null);\n\n useLayoutEffect(() => {\n const halves = Array.from(trackRef.current!.children) as HTMLElement[];\n const reducedMotion = matchMedia(\"(prefers-reduced-motion: reduce)\");\n const phone = matchMedia(\"(max-width: 660px)\");\n let animations: Animation[] = [];\n let width = 0;\n let speed = 0;\n\n const update = () => {\n const nextWidth = halves[0].getBoundingClientRect().width;\n const nextSpeed = phone.matches ? 64 : 16;\n if (reducedMotion.matches) {\n animations.forEach((animation) => animation.cancel());\n animations = [];\n return;\n }\n if (\n !nextWidth ||\n (animations.length && width === nextWidth && speed === nextSpeed)\n ) return;\n\n // Keep the visible phase when resizing or when the web font arrives.\n const distance = (Number(animations[0]?.currentTime ?? 0) * speed) / 1000;\n animations.forEach((animation) => animation.cancel());\n width = nextWidth;\n speed = nextSpeed;\n const halfDuration = (width / speed) * 1000;\n const elapsed = ((distance % width) / speed) * 1000;\n const startTime = Number(document.timeline.currentTime) - elapsed;\n\n animations = halves.map((half, index) => {\n // The two halves share a clock. Each wraps only outside the window;\n // Web Animations don't dispatch CSS animationiteration events to React.\n const animation = half.animate(\n [\n { transform: \"translateX(100%)\" },\n { transform: \"translateX(-100%)\" },\n ],\n {\n duration: halfDuration * 2,\n delay: index === 0 ? -halfDuration : 0,\n iterations: Infinity,\n easing: \"linear\",\n },\n );\n animation.startTime = startTime;\n return animation;\n });\n };\n\n const observer = new ResizeObserver(update);\n observer.observe(halves[0]);\n reducedMotion.addEventListener(\"change\", update);\n phone.addEventListener(\"change\", update);\n update();\n return () => {\n observer.disconnect();\n reducedMotion.removeEventListener(\"change\", update);\n phone.removeEventListener(\"change\", update);\n animations.forEach((animation) => animation.cancel());\n };\n }, []);\n\n return (\n", + "export function Marquee() {\n return (\n"], + ["
\n
\n {[0, 1].map((half) => (\n", + "
\n
\n {[0, 1].map((half) => (\n"] + ] }; for(const [f,edits] of Object.entries(editedFiles)){ let text=read('web/src/'+f); @@ -52,51 +92,17 @@ function calls(text,file) { } // Remove the first occurrence of each listed item; every listed item must be present. function without(list,items,what){const rest=[...list];for(const x of items){const i=rest.indexOf(x);assert.ok(i>=0,what+': expected '+x);rest.splice(i,1);}return rest;} -// Exact, documented differences of the guarded calls (everything else must equal HEAD, in order). -const expected={ - 'governance.ts':{fix:'F1, F15, F16',why:'F1 minLiquidity parsed as uint128 (uint(v, 128) in minLiquidityOf); F16 address(v) in newOwner/newGuardian/feeRecipient (address() plus a refusal of the vault); F15 the current settings() passed down only to refuse a Sunday 2:00-2:59 am Hours start under Dst 0. The Action fields and the propose call are otherwise unchanged (see the differential encoding below).', - removed:['uint(v.minLiquidity)','address(v.next)','recipient(v.recipient)','settingValues(v)','vault("propose", [proposalAction(kind, v)])','proposalAction(kind, v)'], - added:['uint(v, 128)','address(v)','address(v)','address(v)','settingValues(v, settings)','vault("propose", [proposalAction(kind, v, settings)])','proposalAction(kind, v, settings)']}, - 'Owner.tsx':{fix:'F15, F16',why:'F15 the Setting preview and build pass the current settings() (Dst) to settingValues/proposalSpec; F16 Transfer ownership parses the address with newOwner (address() plus a refusal of the vault) before the unchanged guardian read and vault("transferOwnership", [next]).', - removed:['settingValues({ ...v, setting: String(key), })','proposalSpec(10, { ...v, setting: String(key) })','address(v.next)'], - added:['settingValues({ ...v, setting: String(key), }, current)','proposalSpec(10, { ...v, setting: String(key) }, current)']}, - 'wallet.tsx':{fix:'F13',why:'waitForTransactionReceipt gains onReplaced to learn whether the wallet cancelled or replaced the transaction (then the page says so instead of Confirmed). The eth_sendTransaction request, its data (encode(s)), gas, nonce read and simulation are unchanged.', - removed:['client.waitForTransactionReceipt({ hash: tx, timeout: 180000, pollingInterval: 3000, })'], - added:['client.waitForTransactionReceipt({ hash: tx, timeout: 180000, pollingInterval: 3000, onReplaced: (r) => { replaced = r.reason; }, })']}, -}; -// Owner.tsx action attributes that change, in page order, each with its send calls compared below. A6 G-B: each one, -// printed without comments, must also equal HEAD's text once its documented pieces [v9, HEAD] are put back (each -// exactly once), so the listing loop's row (checkedRows[i]), the listGenesis arguments, the new owner -// (newOwner(v.next)) and everything else around the sends are pinned, not only the send calls. -const ownerAttributes=[ - ['onClick','List stocks / Resume listing button: F13 cancelled-in-wallet and not-listed-after-receipt words; same listingPending, inspectListing and w.send(vault("listGenesis", ...)) calls',[ - ['let rowName = "Listing", rowNumber = 0;','let rowName = "Listing";'],['rowNumber = i + 1; ',''], - ['if (!after.listed) throw new TransactionCancelled("Not listed after the receipt."); if (after.error) throw new InputError(after.error);','if (!after.listed || after.error) throw new InputError(after.error || "Listing confirmation unreadable. Re-check before continuing.");'], - ['if (e instanceof TransactionCancelled) { const text = `Row ${rowNumber}: the transaction was cancelled in the wallet; nothing was listed. Press Resume listing.`; setError(text); w.notice("listing", text); } else setError(','setError(']]], - ['onClick','Wait for pending transaction button: F13 also clears the stale error',[['setPending(""); setError(""); setResume(true);','setPending(""); setResume(true);']]], - ['build','Set form: F15 passes the current settings() to proposalSpec(10, ...)',[['String(key) }, current)','String(key) })']]], - ['disabledReason','Finalize genesis: F14 "Paste the listing rows first..." and the listed-with-other-values words',[['{!listing.trim() ? PASTE_FIRST : listingError','{listingError'],['. List them first.` : differing.length ? differWords(differing) : "At least','. List them first.` : "At least']]], - ['disabled','Finalize genesis: F14 also disabled while the paste box is empty or a pasted row is listed with other values',[['finalized !== false || !listing.trim() || !!listingError','finalized !== false || !!listingError'],['!!unlisted.length || !!differing.length ||','!!unlisted.length ||']]], - ['build','Finalize genesis: F14 refuses an empty paste box and listed-with-other-values rows before the unchanged vault("finalizeGenesis")',[ - ['{ if (!listing.trim()) throw new InputError(PASTE_FIRST); await w.listingPending();','{ await w.listingPending();'], - ['const pasted = parseLaunch(listing); const missing = pasted.filter((r) => !fresh.assets.some((a) => same(a.token, r.token)));','const missing = listing.trim() ? parseLaunch(listing).filter((r) => !fresh.assets.some((a) => same(a.token, r.token))) : [];'], - ['const changed = pasted.filter((r) => listedDiffers(fresh.assets, r)); if (changed.length) throw new InputError(differWords(changed.map((r) => r.ticker))); ',''], - ['${reasonWords(a.reason, fresh.globals.settings)}','${a.reason === undefined ? "unreadable" : reasons[a.reason]}']]], - ['build','Transfer ownership: F16 newOwner(v.next) refuses the vault before the unchanged vault("transferOwnership", [next])',[['const next = newOwner(v.next);','const next = address(v.next);']]], -]; +// Guarded calls: v10 changes none, so in every file they equal HEAD in order (no documented difference). +const expected={}; const putBack=(text,pieces,what)=>{for(const [n,o] of pieces){assert.equal(text.split(n).length,2,what+': expected once: '+n);text=text.split(n).join(o);}return text;}; -// Every call expression must equal HEAD in these files, except the listed exact pairs (F11: Redeem legs by assetTokens index). -const allCallsEqual={'components.tsx':[], 'Docs.tsx':[], 'Flows.tsx':[ - ['s.assets.map((a, i) => (

{a.symbol}:{" "} {fmt(quote.amounts[i], a.tokenDecimals)}

))','s.assets.map((a) => (

{a.symbol}:{" "} {fmt(quote.amounts[a.index], a.tokenDecimals)}

))'], - ['fmt(quote.amounts[i], a.tokenDecimals)','fmt(quote.amounts[a.index], a.tokenDecimals)']]}; -// Vault.tsx (v9 with the Stock shelves labels): no read, check or send call at all (no guarded call here, and no send -// or request in the scan below). Its action attributes are pinned in page order: HEAD's Retry vault button and the -// vault, Stock Token and price feed address links, plus the shelves' controls (label Details, drawer Close, filters, -// search, Show all), which only change what is shown; HEAD's search box value/onChange pair is replaced by the -// shelves' search. The StockShelves handlers behind those controls are pinned too. -const vaultAttributes={ - head:['onClick={s.retry}','value={VAULT}','value={search}','onChange={(e) => setSearch(e.target.value)}','value={a.token}','value={a.feed}'], - now:['onClick={s.retry}','value={VAULT}','onClick={onToggle}','onClick={onClose}','value={a.token}','value={a.feed}','role="list"','role="group"','onClick={() => show(f, search)}','value={search}','onChange={(e) => show(filter, e.target.value)}','role="status"','onClick={() => show("all", "")}','onClick={() => show("all", "")}']}; +// Every call expression must equal HEAD in these files, with no exception in v10. +const allCallsEqual={'components.tsx':[], 'Docs.tsx':[], 'Flows.tsx':[]}; +// Vault.tsx (the Stock shelves labels): no read, check or send call at all (no guarded call here, and no send or +// request in the scan below). Its action attributes equal HEAD's and this pinned list, in page order: the Retry vault +// button and the vault, Stock Token and price feed address links, plus the shelves' controls (label Details, drawer +// Close, filters, search, Show all), which only change what is shown. v10 changes none of them (only label and +// drawer words). The StockShelves handlers behind those controls are pinned too. +const vaultAttributes=['onClick={s.retry}','value={VAULT}','onClick={onToggle}','onClick={onClose}','value={a.token}','value={a.feed}','role="list"','role="group"','onClick={() => show(f, search)}','value={search}','onChange={(e) => show(filter, e.target.value)}','role="status"','onClick={() => show("all", "")}','onClick={() => show("all", "")}']; const vaultHandlers={ show:'const show = (f: ShelfFilter, text: string) => { stopRestock(); setOpenToken(undefined); setFilter(f); setSearch(text); };', toggle:'const toggle = (token: string) => setOpenToken(openToken === token ? undefined : token);', @@ -112,34 +118,22 @@ function localDeclarations(file,text,fn,list){ } const sources=git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)); assert.deepEqual(git(['ls-files','--cached','--others','--exclude-standard','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)).sort(),[...sources].sort(),'No source file added or removed'); -const counts={},ownerChanges=[]; +// Every source is either frozen (byte-identical to HEAD) or a v10 file with its documented pieces (above). +assert.deepEqual([...sources].sort(),[...protectedFiles,...Object.keys(editedFiles)].filter(f=>/\.tsx?$/.test(f)).sort(),'every web/src source is frozen or a documented v10 file'); +const counts={}; for(const f of sources){ const old=calls(baseline('web/src/'+f),f),now=calls(read('web/src/'+f),f),e=expected[f]; if(e) assert.deepEqual(without(now.found,e.added,f),without(old.found,e.removed,f),f+' changed guarded calls beyond the documented ones'); else assert.deepEqual(now.found,old.found,f+' changed calldata/read/check call expressions'); + // Action/send/check attributes: equal HEAD in every file, in order (no exception in v10). + assert.equal(now.attributes.length,old.attributes.length,f+' action attribute count'); + assert.deepEqual(now.attributes.map((a,i)=>[old.attributes[i],a]).filter(([o,a])=>o.text!==a.text).map(([,a])=>a.where+': '+a.text),[],f+' changed an action, send or check attribute'); if(f==='Vault.tsx'){ assert.deepEqual(now.found,[],'Vault.tsx makes no read, check or send call'); - assert.deepEqual(old.attributes.map(a=>a.text),vaultAttributes.head,'Vault.tsx HEAD action attributes'); - assert.deepEqual(now.attributes.map(a=>a.text),vaultAttributes.now,'Vault.tsx changed an action attribute beyond the pinned Stock shelves controls'); + assert.deepEqual(now.attributes.map(a=>a.text),vaultAttributes,'Vault.tsx action attributes differ from the pinned Stock shelves controls'); for(const a of now.attributes) assert.deepEqual(a.guarded,[],'Vault.tsx '+a.where+': guarded call in an attribute'); const h=localDeclarations('web/src/Vault.tsx',read('web/src/Vault.tsx'),'StockShelves',Object.keys(vaultHandlers)); for(const [k,v] of Object.entries(vaultHandlers)) assert.equal(h[k],v,'Vault.tsx StockShelves '+k+' differs from the pinned text'); - } else { - assert.equal(now.attributes.length,old.attributes.length,f+' action attribute count'); - const changed=now.attributes.map((a,i)=>[old.attributes[i],a]).filter(([o,a])=>o.text!==a.text); - if(f==='Owner.tsx'){ - assert.deepEqual(changed.map(([,a])=>a.name),ownerAttributes.map(x=>x[0]),'Owner.tsx changed action attributes'); - changed.forEach(([o,a],i)=>{ - assert.equal(o.name,a.name); - // The send calls inside each changed attribute: equal, apart from the documented Owner.tsx differences. - const removed=o.guarded.filter(x=>!a.guarded.includes(x)),added=a.guarded.filter(x=>!o.guarded.includes(x)); - for(const x of removed) assert.ok(e.removed.includes(x),'Owner.tsx '+a.where+': removed '+x); - for(const x of added) assert.ok(e.added.includes(x),'Owner.tsx '+a.where+': added '+x); - assert.deepEqual(a.guarded.filter(x=>!added.includes(x)),o.guarded.filter(x=>!removed.includes(x)),'Owner.tsx '+a.where+' send call order'); - assert.equal(putBack(a.text,ownerAttributes[i][2],'Owner.tsx '+a.where),o.text,'Owner.tsx '+a.where+' changed beyond its documented pieces'); - ownerChanges.push({attribute:a.name,element:a.where,change:ownerAttributes[i][1],pieces:ownerAttributes[i][2].length,sendCallsBefore:o.guarded,sendCallsAfter:a.guarded}); - }); - } else assert.deepEqual(changed.map(([,a])=>a.text),[],f+' changed an action, send or check attribute'); } if(allCallsEqual[f]){ const pairs=allCallsEqual[f]; @@ -176,18 +170,10 @@ for(const f of sources){ } // components.tsx (the shared transaction button) keeps exactly HEAD's single wallet.send. assert.deepEqual(sendUses['components.tsx'].filter(x=>/\(/.test(x)),['wallet.send(await getSpec(), scope, scope)'],'components.tsx sends'); -// wallet.tsx send(), the only function that prompts the wallet: printed without comments it must equal HEAD once the -// documented pieces are put back, so from, to, data, gas (estimate x 1.3), nonce, the chain and account rechecks, -// the simulation and the eth_sendTransaction request are unchanged. F13: learn a cancel/replacement from -// waitForTransactionReceipt and say so (no "Confirmed", no link); F7/A5: the proposal detail wording (its end rounded down). -const sendEdits=[ - ['let replaced: string | undefined; ',''], - ['onReplaced: (r) => { replaced = r.reason; }, ',''], - ['if (replaced === "cancelled" || replaced === "replaced") throw new TransactionCancelled(replaced === "cancelled" ? "The transaction was cancelled in the wallet; it did not run. Refresh the vault before retrying." : "The transaction was replaced in the wallet by a different one; it did not run. Refresh the vault before retrying."); ',''], - ['until just before ${date(expiresAt, "end")}, from the Proposed event.','until ${date(expiresAt)} (exclusive), from the Proposed event.'], - ['if (e instanceof TransactionCancelled) { notice(key, e.message); throw e; } ',''], - ['if (sent && !settled && s.functionName === "listGenesis") pendingShown.current = true; ',''], -]; +// wallet.tsx send(), the only function that prompts the wallet: printed without comments it must equal HEAD (v10 +// documents no piece), so from, to, data, gas (estimate x 1.3), nonce, the chain and account rechecks, the simulation +// and the eth_sendTransaction request are unchanged. +const sendEdits=[]; function sendText(text){ const tree=ts.createSourceFile('wallet.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX),printer=ts.createPrinter({removeComments:true});let out; (function v(n){if(ts.isFunctionDeclaration(n)&&n.name?.text==='send')out=printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');ts.forEachChild(n,v);})(tree); @@ -195,18 +181,18 @@ function sendText(text){ } let sendNow=sendText(read('web/src/wallet.tsx')); for(const [n,o] of sendEdits){assert.equal(sendNow.split(n).length,2,'wallet.tsx send(): expected once: '+n);sendNow=sendNow.split(n).join(o);} -assert.equal(sendNow,sendText(baseline('web/src/wallet.tsx')),'wallet.tsx send() changed beyond the documented F13/F7/A5 pieces'); +assert.equal(sendNow,sendText(baseline('web/src/wallet.tsx')),'wallet.tsx send() changed (v10 changes nothing in it)'); // Differential encoding: HEAD and current governance/model/chain bundled side by side. For the same inputs the -// calldata must be byte-identical whenever the current page accepts; the current page may only add the -// documented refusals (made before any wallet prompt), and never accepts what HEAD refused. +// calldata must be byte-identical whenever the current page accepts; v10 adds no refusal, never accepts what HEAD +// refused, and refuses with HEAD's words. const scratch=path.join(root,'test/scratch/calldata'); fs.rmSync(scratch,{recursive:true,force:true}); for(const p of git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.(tsx?|json)$/.test(p))){ const out=path.join(scratch,'head',p.slice(4));fs.mkdirSync(path.dirname(out),{recursive:true});fs.writeFileSync(out,baseline(p)); } -const entry=src=>`export { proposalSpec, proposalAction } from "${src}/governance";\nexport { parseLaunch, address, recipient, amount, uint, deadline, depositArgs, redeemArgs } from "${src}/model";\nexport { encode, vault, token, VAULT } from "${src}/chain";\n`; +const entry=src=>`export { proposalSpec, proposalAction } from "${src}/governance";\nexport { parseLaunch, address, recipient, amount, uint, deadline, depositArgs, redeemArgs } from "${src}/model";\nexport { encode, vault, token, VAULT } from "${src}/chain";\nexport { newOwner } from "${src}/governance";\n`; fs.writeFileSync(path.join(scratch,'head-entry.ts'),entry('./head/src')); -fs.writeFileSync(path.join(scratch,'now-entry.ts'),entry('../../../web/src')+'export { newOwner } from "../../../web/src/governance";\n'); +fs.writeFileSync(path.join(scratch,'now-entry.ts'),entry('../../../web/src')); const bundle=async name=>{const outfile=path.join(scratch,name+'.mjs');await build({entryPoints:[path.join(scratch,name+'-entry.ts')],bundle:true,format:'esm',platform:'node',outfile,nodePaths:[path.resolve('node_modules')],logLevel:'error'});return import(pathToFileURL(outfile).href);}; const head=await bundle('head'),now=await bundle('now'); const SPRING='Sunday 2:00-2:59 am New York is skipped on the spring-forward Sunday; choose another start.'; @@ -230,24 +216,18 @@ for(let k=0;k<=16;k++)if(k!==5)for(const value of values)cases.push([10,{setting for(const from of hourTexts)for(const to of hourTexts)for(const settings of settingsList)cases.push([10,{setting:'5',from,to},settings]); for(const k of [11,-1])cases.push([k,{}]); const attempt=f=>{try{return {hex:f()};}catch(e){return {error:e.message};}}; -const tally={sameCalldata:0,bothRefused:0,newRefusals:{}}; +const tally={sameCalldata:0,bothRefused:0,newRefusals:{},refusedBoth:{}}; function compare(label,input,h,n,newRefusalAllowed,t=tally){ if(n.hex!==undefined){assert.ok(h.hex!==undefined,label+' accepted input HEAD refused: '+JSON.stringify(input));assert.equal(n.hex,h.hex,label+' calldata changed for '+JSON.stringify(input));t.sameCalldata++;} else if(h.hex!==undefined){assert.ok(newRefusalAllowed(n.error),label+' new refusal not documented: '+n.error+' for '+JSON.stringify(input));t.newRefusals[n.error]=(t.newRefusals[n.error]||0)+1;} - else t.bothRefused++; + else {assert.equal(n.error,h.error,label+' refusal words changed for '+JSON.stringify(input));t.bothRefused++;if(t.refusedBoth)t.refusedBoth[n.error]=(t.refusedBoth[n.error]||0)+1;} } -const isV=x=>typeof x==='string'&&x.toLowerCase()===V.toLowerCase(); const json=x=>JSON.parse(JSON.stringify(x,(k,v)=>typeof v==='bigint'?String(v):v)); for(const [kind,v,settings] of cases){ - const h=attempt(()=>head.encode(head.proposalSpec(kind,v))),n=attempt(()=>now.encode(now.proposalSpec(kind,v,settings))); - compare('propose',json({kind,v,settings}),h,n,error=>{ - if(error===U128)return [0,5].includes(kind)&&/^\d+$/.test(v.minLiquidity)&&BigInt(v.minLiquidity)>=2n**128n&&BigInt(v.minLiquidity)<2n**256n; - if(error==='The new guardian must not be the vault.')return kind===7&&isV(v.next); - if(error===SPRING){const from=kind===10&&v.setting==='5'?head.proposalAction(kind,v).value:-1n;return from>=7200n&&from<10800n&&(!settings||settings.dst===0n);} - return false; - }); + const h=attempt(()=>head.encode(head.proposalSpec(kind,v,settings))),n=attempt(()=>now.encode(now.proposalSpec(kind,v,settings))); + compare('propose',json({kind,v,settings}),h,n,()=>false); } -for(const next of addrs)compare('transferOwnership',{next},attempt(()=>head.encode(head.vault('transferOwnership',[head.address(next)]))),attempt(()=>now.encode(now.vault('transferOwnership',[now.newOwner(next)]))),error=>error==='The new owner must not be the vault.'&&isV(next)); +for(const next of addrs)compare('transferOwnership',{next},attempt(()=>head.encode(head.vault('transferOwnership',[head.newOwner(next)]))),attempt(()=>now.encode(now.vault('transferOwnership',[now.newOwner(next)]))),()=>false); for(const pool of [A,Z])for(const quoteFeed of [B,Z])for(const minLiquidity of liqs){ const text=`AAA ${A} ${B} ${pool} ${quoteFeed} ${minLiquidity}\nBBB ${B} ${A} ${Z} ${Z} 0`; const listing=m=>m.parseLaunch(text).map(r=>m.encode(m.vault('listGenesis',[r.token,r.feed,r.pool,r.quoteFeed,r.minLiquidity]))).join(); @@ -314,15 +294,15 @@ for(const [file,list] of Object.entries(pinned)){ for(const k of list)assert.equal(n[k],h[k],file+': '+k+' changed'); } // A6 G-B: where the send arguments come from. Printed without comments (whitespace folded), each declaration equals -// HEAD once its documented pieces [v9, HEAD] are put back, or equals the pinned v9 text (new in v9): the Guardian and +// HEAD (v10 documents no piece: [v10, HEAD] lists are empty), or equals the pinned v9 text: the Guardian and // FeeRecipient proposal targets (newGuardian(v.next), feeRecipient(v.recipient)), the parsers they use, and the -// listing rows (checkRows, unchanged) and the Finalize helpers. +// listing rows (checkRows) and the Finalize helpers. const pinnedV9={ 'web/src/governance.ts':{ - proposalAction:[['v: Record, settings?: any): Action','v: Record): Action'],['target: newGuardian(v.next)','target: address(v.next)'],['target: feeRecipient(v.recipient)','target: recipient(v.recipient)'],['settingValues(v, settings)','settingValues(v)']], - proposalSpec:[['v: Record, settings?: any) { return vault("propose", [proposalAction(kind, v, settings)]); }','v: Record) { return vault("propose", [proposalAction(kind, v)]); }']], - settingValues:[['v: Record, settings?: any): [','v: Record): ['],['if (springForwardStart(from, settings ? BigInt(settings.dst) : 0n)) throw new InputError(SPRING_FORWARD_START); ','']], - poolValues:[['l = minLiquidityOf(v.minLiquidity);','l = uint(v.minLiquidity);']], + proposalAction:[], + proposalSpec:[], + settingValues:[], + poolValues:[], newOwner:'export function newOwner(v: string): Address { const next = address(v); if (same(next, VAULT)) throw new InputError("The new owner must not be the vault."); return next; }', newGuardian:'function newGuardian(v: string): Address { const next = address(v); if (same(next, VAULT)) throw new InputError("The new guardian must not be the vault."); return next; }', feeRecipient:'function feeRecipient(v: string): Address { const target = address(v); if (same(target, VAULT)) throw new InputError("The fee recipient must not be the vault."); return target; }', @@ -336,6 +316,11 @@ const pinnedV9={ PASTE_FIRST:'const PASTE_FIRST = "Paste the listing rows first so the page can check none is missing.";'}, }; const one=s=>s.replace(/\s+/g,' '); +// The v9 text, as pinned, also stands at HEAD. +for(const [file,map] of Object.entries(pinnedV9)){ + const h=declarations(file,baseline(file),Object.keys(map)); + for(const [k,x] of Object.entries(map))if(typeof x==='string')assert.equal(one(h[k]),x,file+': '+k+' at HEAD differs from the pinned v9 text'); +} for(const [file,map] of Object.entries(pinnedV9)){ const list=Object.keys(map),n=declarations(file,read(file),list),old=list.filter(k=>typeof map[k]!=='string'),h=declarations(file,baseline(file),old); for(const k of list)assert.equal(typeof map[k]==='string'?one(n[k]):putBack(one(n[k]),map[k],file+' '+k),typeof map[k]==='string'?map[k]:one(h[k]),file+': '+k+' differs from the pinned v9 text'); @@ -354,23 +339,26 @@ for(const [file,map] of Object.entries(imports)){ for(const [k,m] of Object.entries(map))assert.deepEqual(bound[k],[m],file+': '+k+' is bound only by its import from '+m); } assert.equal(now.VAULT,head.VAULT,'the vault address the encoders use'); -for(const m of [U128,'The new guardian must not be the vault.','The new owner must not be the vault.',SPRING]) assert.ok(tally.newRefusals[m]>0,'corpus exercises: '+m); +// The v9 refusals (uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0) are +// still made, by HEAD and now alike, and v10 adds none. +for(const m of [U128,'The new guardian must not be the vault.','The new owner must not be the vault.',SPRING]) assert.ok(tally.refusedBoth[m]>0,'corpus exercises (refused by HEAD and now alike): '+m); +assert.deepEqual(tally.newRefusals,{},'no new refusal'); assert.ok(tally.sameCalldata>400,'corpus size '+JSON.stringify(tally)); fs.rmSync(scratch,{recursive:true,force:true}); -fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:{'main.tsx':'A4: the footer tagline span removed','Scenery.tsx':'A2: eight slogan lines per strip half instead of four'},counts, - documentedGuardedCallChanges:expected,ownerActionAttributeChanges:ownerChanges,vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},walletSendDocumentedPieces:sendEdits.map(([now,head])=>({now,head})), +fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.length+' documented pieces [v10, HEAD]'])),v10Pieces:editedFiles,counts, + documentedGuardedCallChanges:expected,vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},walletSendDocumentedPieces:sendEdits.map(([now,head])=>({now,head})), differentialEncoding:{cases:cases.length,...tally},sendBuilders:{clocks,...sends},pinnedDeclarations:pinned, sendsAndRequests:{sendFreeFiles:NO_SEND,usesPerFile:Object.fromEntries(Object.entries(sendUses).map(([f,x])=>[f,x.length])),components:sendUses['components.tsx'].filter(x=>/\(/.test(x))}, - pinnedV9Declarations:Object.fromEntries(Object.entries(pinnedV9).map(([f,m])=>[f,Object.fromEntries(Object.entries(m).map(([k,x])=>[k,typeof x==='string'?'v9 text':x.length+' pieces from HEAD']))])),importBindings:imports, - checks:['Losses.tsx, poolMath.ts, deployment.ts and vault.abi.json byte-identical to HEAD; main.tsx and Scenery.tsx byte-identical to HEAD once the one documented edit is put back (A4 footer tagline removed; A2 eight slogan lines per half); no source file added or removed', - 'Every vault/token/feed/read call, transaction argument parser, encoding, simulation and send (guarded names) in every web/src source equals the HEAD AST in order, apart from the exact documented changes listed here', - 'Action/send/check JSX attributes equal HEAD in every file except the seven listed Owner.tsx attributes, whose send calls are compared one by one and whose whole printed text equals HEAD once their documented pieces are put back (the listing row checkedRows[i], the listGenesis arguments, newOwner(v.next) included), and Vault.tsx, whose attributes equal the pinned list (HEAD\'s Retry vault button and address links plus the Stock shelves controls, none with a guarded call) and whose StockShelves handlers equal their pinned text; Vault.tsx has no guarded call at all', + pinnedV9Declarations:Object.fromEntries(Object.entries(pinnedV9).map(([f,m])=>[f,Object.fromEntries(Object.entries(m).map(([k,x])=>[k,typeof x==='string'?'v9 text (also at HEAD)':'equal to HEAD ('+x.length+' pieces)']))])),importBindings:imports, + checks:['v10 scope: Losses, poolMath, deployment, the ABI, main, components, Owner, wallet, governance, chain and newYork byte-identical to HEAD; Docs.tsx, Flows.tsx, Vault.tsx and model.ts byte-identical to HEAD once their documented v10 pieces are put back (Docs: capital D; Flows: the HoursTag "Redemptions are open 24/7. Deposit hours apply to deposits only." at the top of the Deposit and Redeem forms and "Redemption is always open." out of the Redeem title; Vault: no price time on the labels or in the drawer, the reason-9 "old price" tag kept; model: age() removed); Scenery.tsx byte-identical to HEAD once its two slogan strip pieces are put back (the react import with the Marquee hook that runs each half as its own Web Animation, and the track ref); no source file added or removed', + 'Every vault/token/feed/read call, transaction argument parser, encoding, simulation and send (guarded names) in every web/src source equals the HEAD AST in order (v10 changes none)', + 'Action/send/check JSX attributes equal HEAD in every file, in order (v10 changes none); Vault.tsx has no guarded call at all, its attributes equal the pinned list (Retry vault button and address links plus the Stock shelves controls, none with a guarded call) and its StockShelves handlers equal their pinned text', 'Wallet sends and raw requests: in every web/src file the uses of a send/request name or of the provider (ethereum) with their calls, every string naming a sending or signing method, computed access to the global object and eval/Function/Reflect equal HEAD in order, with no exception; Vault, Scenery, main and Docs have no send or request (main only reads window.ethereum), components keeps the single wallet.send of HEAD', - 'Argument sources: governance.ts proposalAction (Guardian target newGuardian(v.next), FeeRecipient target feeRecipient(v.recipient)), proposalSpec, settingValues and poolValues equal HEAD once their documented pieces are put back; newOwner, newGuardian, feeRecipient, minLiquidityOf and the spring-forward rule equal the pinned v9 text; Owner.tsx checkRows equals HEAD and the Finalize helpers equal the pinned v9 text; the names these call are bound only by their imports; the encoders use the same vault address', - 'wallet.tsx send() (the only wallet prompt) equals HEAD once the six documented F13/F7/A5 pieces are put back: from, to, data, gas, nonce, chain and account rechecks, simulation and the eth_sendTransaction request unchanged', - 'Every call expression in components.tsx, Docs.tsx and Flows.tsx equals HEAD, apart from the F11 Redeem-leg index pair', - 'Differential encoding of HEAD and current governance/model/chain: identical calldata for every input the page accepts (proposals of every kind, transfer ownership, genesis listing); the only new refusals are the documented ones (uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0); nothing HEAD refused is accepted', + 'Argument sources: governance.ts proposalAction (Guardian target newGuardian(v.next), FeeRecipient target feeRecipient(v.recipient)), proposalSpec, settingValues and poolValues equal HEAD; newOwner, newGuardian, feeRecipient, minLiquidityOf and the spring-forward rule equal the pinned v9 text (at HEAD and now); Owner.tsx checkRows equals HEAD and the Finalize helpers equal the pinned v9 text; the names these call are bound only by their imports; the encoders use the same vault address', + 'wallet.tsx send() (the only wallet prompt) equals HEAD: from, to, data, gas, nonce, chain and account rechecks, simulation and the eth_sendTransaction request unchanged', + 'Every call expression in components.tsx, Docs.tsx and Flows.tsx equals HEAD (the 24/7 tag is markup only)', + 'Differential encoding of HEAD and current governance/model/chain: identical calldata for every input the page accepts (proposals of every kind, transfer ownership, genesis listing); no new refusal, nothing HEAD refused is accepted, and every refusal has HEAD\'s words (the v9 refusals, uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0, are still made by both)', 'Under six fixed clocks: deadline() and the full depositArgs and redeemArgs tuples (deadline included) equal HEAD, and so does the encoded deposit and redeem calldata; redeem built as Flows does (amount, recipient, redeemArgs), approve (amount with 0/6/8/18 decimals), claim (recipient), lowerNAVCap (amount, zero allowed), close and removeRetired (address), and the uint and address parsers give the same value or are refused by both, with no new refusal', 'The printed text (no comments) of same, deadline, depositArgs, redeemArgs, amount, address, recipient, uint and parseLaunch in model.ts and of vault, token and encode in chain.ts equals HEAD', - 'Only the look and fix files change in web/src (check-bundle.mjs)']},null,2)+'\n'); + 'Only the v10 files change in web/src (check-bundle.mjs)']},null,2)+'\n'); console.log('PASS baseline call-expression comparison',counts,'differential encoding',tally,'send builders',sends); diff --git a/web/scripts/check-motion.mjs b/web/scripts/check-motion.mjs index 1e4d93d..76665a5 100644 --- a/web/scripts/check-motion.mjs +++ b/web/scripts/check-motion.mjs @@ -160,44 +160,164 @@ try { await page.setViewportSize({ width, height: 1000 }); await page.goto(url + "#deposit"); await page.evaluate(() => document.fonts.ready); - const track = page.locator(".marquee-track"); - const lengths = await track.evaluate((e) => ({ - track: e.getBoundingClientRect().width, - frame: e.parentElement.getBoundingClientRect().width, - halves: [...e.children].map((h) => ({ - width: h.getBoundingClientRect().width, - shown: [...h.children].filter( - (s) => getComputedStyle(s).display !== "none", - ).length, - })), - willChange: getComputedStyle(e).willChange, - })); - // v9 slogan strip: each half is k whole 720 px lines, k the fewest that cover the viewport; the moving block - // is at most two screens plus two lines wide and has no will-change. + const speed = width <= 660 ? 64 : 16; + // v10: Marquee re-times the loop after a viewport change or the web font's arrival (ResizeObserver): wait for the + // strip's font itself, then up to 3 s for both halves to run at a cycle that matches the half width; the exact + // values are asserted below. + await page.evaluate(() => + document.fonts.load("24px Pixel").then(() => document.fonts.ready), + ); + await page + .waitForFunction( + (speed) => { + const h = document.querySelector(".marquee-half"), + a = document + .querySelector(".marquee") + .getAnimations({ subtree: true }); + return ( + a.length === 2 && + a.every( + (x) => + x.playState === "running" && + Math.abs( + x.effect.getTiming().duration - + ((2 * h.getBoundingClientRect().width) / speed) * 1000, + ) < 0.01, + ) + ); + }, + speed, + { timeout: 3000 }, + ) + .catch(() => {}); + const strip = page.locator(".marquee"); + const lengths = await strip.evaluate((m) => { + const e = m.querySelector(".marquee-track"), + halves = [...e.children]; + return { + track: e.getBoundingClientRect().width, + trackTransform: getComputedStyle(e).transform, + frame: e.parentElement.getBoundingClientRect().width, + halves: halves.map((h) => ({ + width: h.getBoundingClientRect().width, + shown: [...h.children].filter( + (s) => getComputedStyle(s).display !== "none", + ).length, + })), + parts: [m, e.parentElement, e, ...halves, ...m.querySelectorAll("span")].map( + (x) => ({ + willChange: getComputedStyle(x).willChange, + animationName: getComputedStyle(x).animationName, + transform: getComputedStyle(x).transform, + flat: [ + getComputedStyle(x).transformStyle, + getComputedStyle(x).perspective, + getComputedStyle(x).backfaceVisibility, + ].join(" "), + }), + ), + loops: m.getAnimations({ subtree: true }).map((a) => ({ + half: halves.indexOf(a.effect.target), + css: typeof CSSAnimation !== "undefined" && a instanceof CSSAnimation, + iterations: a.effect.getTiming().iterations, + duration: a.effect.getTiming().duration, + start: a.startTime, + keyframes: a.effect + .getKeyframes() + .map((f) => `${f.computedOffset} ${f.transform}`) + .join(", "), + })), + }; + }); + // v9 slogan strip: each half is k whole 720 px lines, k the fewest that cover the viewport. + // v10: the halves share one grid cell and each moves as its own endless Web Animation (no CSS animation, no + // will-change, no 3D), so the moving blocks are the two halves (k lines each, at most the viewport plus one + // line), the track stays put; speed exactly 16 px/s (64 px/s at 660 px and below), two half widths per cycle. const k = Math.min(8, Math.max(1, Math.ceil(width / 720))); for (const h of lengths.halves) { assert.equal(h.shown, k, `${k} lines per half at ${width}`); assert.ok(Math.abs(h.width - 720 * k) < 0.5 * k, `Half width ${width}`); } + const L = lengths.halves[0].width; assert.ok( - 720 * k >= width && lengths.track / 2 >= lengths.frame - 0.01, + 720 * k >= width && L >= lengths.frame - 0.01, `No empty stretch ${width}`, ); - assert.ok(lengths.track <= 2 * (width + 720) + 0.5, `Track ${width}`); - assert.equal(lengths.willChange, "auto", `No will-change ${width}`); - for (const position of [0, 0.25, 0.49999, 0.75, 0.99999]) { - const gap = await track.evaluate((e, fraction) => { - const a = e.getAnimations()[0]; - a.pause(); - a.currentTime = a.effect.getTiming().duration * fraction; - const r = e.getBoundingClientRect(), - f = e.parentElement.getBoundingClientRect(); - return r.right < f.right || r.left > f.left; + assert.ok( + Math.abs(lengths.track - L) < 0.01 && + lengths.trackTransform === "none" && + L <= width + 720 + 0.5 * k, + `Moving blocks ${width}`, + ); + for (const p of lengths.parts) { + assert.equal(p.willChange, "auto", `No will-change ${width}`); + assert.equal(p.animationName, "none", `No CSS animation ${width}`); + assert.ok( + p.transform === "none" || + /^matrix\(1, 0, 0, 1, -?[\d.e+-]+, 0\)$/.test(p.transform), + `No 3D ${width}: ${p.transform}`, + ); + assert.equal(p.flat, "flat none visible", `No 3D ${width}`); + } + assert.deepEqual( + lengths.loops.map((x) => [x.half, x.css, x.iterations]).sort(), + [ + [0, false, Infinity], + [1, false, Infinity], + ], + `One endless Web Animation on each half ${width}`, + ); + for (const x of lengths.loops) + assert.ok( + Math.abs((2 * L) / (x.duration / 1000) - speed) < 1e-6 && + x.start === lengths.loops[0].start && + x.keyframes === "0 translateX(100%), 1 translateX(-100%)", + `Speed ${width}: ${JSON.stringify(x)}`, + ); + // Paused at loop phases (as fractions of a half's cycle, just before and after each half's reset among them), + // the halves sit edge to edge across the whole window. + for (const position of [ + 0, 1e-7, 0.25, 0.4999999, 0.5, 0.5000001, 0.75, 0.9999999, + ]) { + const gap = await strip.evaluate((m, fraction) => { + for (const a of m.getAnimations({ subtree: true })) { + a.pause(); + a.currentTime = a.effect.getTiming().duration * fraction; + } + const f = m.querySelector(".marquee-window").getBoundingClientRect(), + [a, b] = [...m.querySelectorAll(".marquee-half")] + .map((h) => h.getBoundingClientRect()) + .sort((x, y) => x.left - y.left); + return Math.max(0, a.left - f.left, b.left - a.right, f.right - b.right); }, position); - assert.equal(gap, false); + assert.ok(gap < 0.01, `Blank of ${gap} px at ${position} ${width}`); } + // Real playback across both resets: no animationiteration event reaches the page. + const wrap = await strip.evaluate(async (m) => { + const a = m.getAnimations({ subtree: true }), + d = a[0].effect.getTiming().duration, + crossed = []; + let events = 0; + const count = () => events++; + document.addEventListener("animationiteration", count, true); + for (const at of [d / 2, d]) { + for (const x of a) { + x.currentTime = at - 150; + x.play(); + } + await new Promise((r) => setTimeout(r, 450)); + crossed.push(a[0].currentTime > at); + } + document.removeEventListener("animationiteration", count, true); + return { events, crossed }; + }); + assert.deepEqual( + wrap, + { events: 0, crossed: [true, true] }, + `Loop ends ${width}`, + ); checks.push( - `Marquee (${k} whole lines per half, no will-change) covers ${width}px at every sampled loop position, DPR ${scale}`, + `Marquee (${k} whole lines per half, each half its own endless Web Animation at ${speed} px/s, no CSS animation, will-change or 3D) covers ${width}px at every sampled loop phase incl. both resets, no animationiteration event across a real reset, DPR ${scale}`, ); } await context.close(); diff --git a/web/scripts/check-visibility.mjs b/web/scripts/check-visibility.mjs index 9b43d1d..3aeadba 100644 --- a/web/scripts/check-visibility.mjs +++ b/web/scripts/check-visibility.mjs @@ -2,33 +2,26 @@ import fs from 'node:fs';import http from 'node:http';import path from 'node:pat const server=http.createServer((req,res)=>{const ps=req.url.split('?')[0].split('/'),root=ps[1]==='old'?process.env.BASKET_BASELINE_DIST:path.resolve('../dist'),p=path.join(root,ps.slice(2).join('/')||'index.html');try{res.setHeader('Content-Type',({'.js':'application/javascript','.css':'text/css','.html':'text/html','.svg':'image/svg+xml','.webp':'image/webp','.woff2':'font/woff2'})[path.extname(p)]||'text/plain');res.end(fs.readFileSync(p));}catch{res.writeHead(404).end();}});await new Promise(r=>server.listen(0,'127.0.0.1',r)); const b=await chromium.launch(),page=await b.newPage({viewport:{width:375,height:812},reducedMotion:'reduce'});const results=[]; const owner=JSON.parse(fs.readFileSync('../artifacts/live-validation.json')).globals.owner; -// v9 look (final, owner's phone mockup): on phones the pop-out character stands in the title panel above the title -// words of Vault, Deposit and Redeem, and the no-wallet hint puts "Copy site link" on its own row (same words, 32 px -// taller). Only these moves are allowed below the first screen; every other element visible on the first screen -// of the previous build must still be visible there, with the same words (line breaks aside). +// v10 (the previous build is v9): nothing may leave the first phone screen. Every element visible on the first screen +// of the previous build must still be visible there, with the same words (line breaks aside), and the title character +// must still start on it. The v10 changes (the 24/7 tag at the top of the Deposit and Redeem forms, the shorter Redeem +// title sentence, no price times on the Stock shelves) all sit below the first screen on phones. const characterPages=['vault','deposit','redeem']; -// v9 A4: the Vault title's eyebrow (the old tagline) is removed on purpose; only that element may vanish, only on Vault. -const taglineWords=/^stocktokens.onebasket.$/i; await page.addInitScript(({owner})=>{if(location.search.includes('connected')) window.ethereum={request:async({method})=>method==='eth_chainId'?'0x1237':[owner]};},{owner}); try{await page.route(/https:\/\//,async route=>{try{const r=await fetch(route.request().url(),{method:'POST',headers:{'content-type':'application/json'},body:route.request().postData()});await route.fulfill({body:await r.text(),contentType:'application/json'});}catch{await route.abort();}}); for(const variant of ['no-wallet','connected']) for(const name of ['vault','deposit','redeem','docs','owner','losses']){const snapshots=[],layout=[];for(const ver of ['old','new']){await page.goto(`http://127.0.0.1:${server.address().port}/${ver}/?${variant}#${name}`);await page.evaluate(()=>document.fonts.ready);await page.waitForFunction(()=>!document.querySelector('button.refresh').disabled);await page.evaluate(()=>scrollTo(0,0));snapshots.push(await page.locator('main p, main input, main button, main select, main textarea, main summary, main .empty, main .note').evaluateAll(es=>es.filter(e=>e.getBoundingClientRect().height>0).map(e=>({tag:e.tagName,text:e.innerText||e.getAttribute('aria-label')||e.name||e.type,y:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom,inTitle:!!e.closest('.page-title')})))); -layout.push(await page.evaluate(()=>{const box=s=>{const e=document.querySelector(s);if(!e)return null;const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height};};return {hint:box('main .wallet-help'),title:box('main .page-title'),picture:box('main .page-title .title-character img.character-picture')};}));} +layout.push(await page.evaluate(()=>{const box=s=>{const e=document.querySelector(s);if(!e)return null;const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height};};const m=document.querySelector('header .marquee'),w=m.querySelector('.marquee-window').getBoundingClientRect(),h=[...m.querySelectorAll('.marquee-half')].map(e=>e.getBoundingClientRect()).sort((a,c)=>a.left-c.left);return {hint:box('main .wallet-help'),title:box('main .page-title'),picture:box('main .page-title .title-character img.character-picture'),strip:{...box('header .marquee'),animations:m.getAnimations({subtree:true}).length,still:h.length===2&&Math.abs(h[0].left-w.left)<0.01&&Math.abs(h[1].left-h[0].right)<0.01&&h[1].right>=w.right-0.01}};}));} const words=t=>String(t).replace(/\s+/g,''),[old,now]=snapshots,[,newLayout]=layout,oldLayout=layout[0]; const hintGrowth=(newLayout.hint?.height??0)-(oldLayout.hint?.height??0); // Where an element of the previous first screen went; null if its words are gone. const counterpart=x=>now.filter(y=>words(y.text)===words(x.text)).sort((a,c)=>Math.abs(a.y-x.y)-Math.abs(c.y-x.y))[0]??null; -const prior=old.filter(x=>x.y>=0&&x.bottom<=812),moved=[],removed=[],regressions=[]; -for(const x of prior){const y=counterpart(x);if(y&&y.bottom<=812)continue; - if(!y&&name==='vault'&&taglineWords.test(words(x.text))){removed.push({tag:x.tag,oldBottom:x.bottom,reason:'old Vault eyebrow (tagline) removed (v9)'});continue;} - // Title words of the character pages: below the character's head, still inside the title panel. - const character=!!y&&y.inTitle&&characterPages.includes(name)&&!!newLayout.picture&&y.y>newLayout.picture.top; - // Title words pushed down by the taller wallet hint only (no wallet), by no more than its growth. - const hint=!!y&&y.inTitle&&variant==='no-wallet'&&hintGrowth>0&&hintGrowth<=40&&y.bottom<=812+hintGrowth+1; - if(character||hint)moved.push({tag:x.tag,text:x.text,oldBottom:x.bottom,newBottom:y.bottom,reason:character?'pop-out character above the title words (v9 look)':'wallet hint '+hintGrowth+' px taller (v9 look)'}); - else regressions.push({...x,now:y});} +const prior=old.filter(x=>x.y>=0&&x.bottom<=812),regressions=[]; +for(const x of prior){const y=counterpart(x);if(y&&y.bottom<=812)continue;regressions.push({...x,now:y});} const checks=[]; +// v10 slogan strip (now two halves, each its own Web Animation): on the phone screen it keeps the previous build's place +// and height, and under reduced motion it has no animation and stands still and full, the halves side by side. +checks.push('slogan strip in the same place and size, still and full under reduced motion');{const o=oldLayout.strip,n=newLayout.strip;if(Math.abs(n.top-o.top)>0.01||Math.abs(n.height-o.height)>0.01||n.animations!==0||!n.still)regressions.push({tag:'DIV',text:'slogan strip',old:o,now:n});} if(characterPages.includes(name)){checks.push('character picture starts on the first screen');if(!newLayout.picture||newLayout.picture.height<=0||newLayout.picture.top<0||newLayout.picture.top>=812)regressions.push({tag:'IMG',text:'title character',now:newLayout.picture});} -// The report leaves the removed tagline's words out. -results.push({variant,name,prior:prior.map(x=>taglineWords.test(words(x.text))?{...x,text:'(old Vault eyebrow, removed)'}:x),hintGrowth,moved,removed,checks,regressions});} -for(const r of results) if(r.regressions.length) throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions,moved:r.moved})); -console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.moved.length,0),'title elements moved by the v9 look (character, wallet hint);',results.reduce((n,r)=>n+r.removed.length,0),'tagline elements removed (Vault)');fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2));}finally{await b.close();await new Promise(r=>server.close(r));} +results.push({variant,name,prior,hintGrowth,checks,regressions});} +for(const r of results) if(r.regressions.length) throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions})); +console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.prior.length,0),'first-screen elements of the previous build still on the first screen with the same words; nothing moved or removed; slogan strip in place, still and full under reduced motion');fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2));}finally{await b.close();await new Promise(r=>server.close(r));} diff --git a/web/scripts/inspect-export.mjs b/web/scripts/inspect-export.mjs index b22b431..c3ae9af 100644 --- a/web/scripts/inspect-export.mjs +++ b/web/scripts/inspect-export.mjs @@ -104,6 +104,29 @@ try { width, heading: await p.locator("h1").innerText(), }); + // v10: the 24/7 redemption tag on Deposit and Redeem only, first in the form, whole inside the viewport. + const tag = await p.locator(".hours-tag").evaluateAll((ts) => + ts.map((t) => ({ + text: t.textContent, + first: t.parentElement.firstElementChild === t, + inView: + t.getBoundingClientRect().left >= 0 && + t.getBoundingClientRect().right <= innerWidth, + })), + ); + assert.deepEqual( + tag, + ["Deposit", "Redeem"].includes(name) + ? [ + { + text: "Redemptions are open 24/7. Deposit hours apply to deposits only.", + first: true, + inView: true, + }, + ] + : [], + `${name} ${width} 24/7 tag`, + ); if (width === 1440 || width === 375) await p.screenshot({ path: path.join(captures, `live-${name.toLowerCase()}-${width}.jpg`), @@ -132,9 +155,36 @@ try { inputSize: getComputedStyle(document.querySelector("input")).fontSize, marquee: getComputedStyle(document.querySelector(".marquee-track")) .animationName, + // v10: the strip's halves move as Web Animations; under reduced motion none may exist (cancelled, not paused), no + // strip element has a CSS animation, and the halves stand still side by side from the window's left edge. + marqueeStill: (() => { + const m = document.querySelector(".marquee"), + w = m.querySelector(".marquee-window").getBoundingClientRect(), + h = [...m.querySelectorAll(".marquee-half")] + .map((e) => e.getBoundingClientRect()) + .sort((a, b) => a.left - b.left); + return { + animations: m.getAnimations({ subtree: true }).length, + cssAnimations: [m, ...m.querySelectorAll("*")].filter( + (e) => getComputedStyle(e).animationName !== "none", + ).length, + halves: h.length, + start: Math.abs(h[0].left - w.left) < 0.01, + sideBySide: Math.abs(h[1].left - h[0].right) < 0.01, + full: h[1].right >= w.right - 0.01, + }; + })(), focus: getComputedStyle(document.querySelector("a")).outlineOffset, })); assert.equal(style.marquee, "none"); + assert.deepEqual(style.marqueeStill, { + animations: 0, + cssAnimations: 0, + halves: 2, + start: true, + sideBySide: true, + full: true, + }); assert.equal(style.inputSize, "16px"); assert.ok(style.fonts.every((f) => f.status === "loaded")); const pairs = [ @@ -179,7 +229,8 @@ try { "Local fonts loaded", "Keyboard skip link reaches main", "No-wallet recovery message", - "Reduced motion disables marquee", + "v10: the 24/7 redemption tag first in the Deposit and Redeem forms at four widths, on no other page", + "Reduced motion disables marquee (v10: no Web Animation or CSS animation on the strip, the halves still and side by side from the left edge)", ], limitations: [ "No screen reader, native zoom or physical device session", diff --git a/web/scripts/run-browser.mjs b/web/scripts/run-browser.mjs index 1de4455..4a21000 100644 --- a/web/scripts/run-browser.mjs +++ b/web/scripts/run-browser.mjs @@ -861,9 +861,12 @@ try { // A US market holiday inside the hours: every feed three hours old. for (const f of feeds) await tx(f, compiled.TestFeed.abi, "setLag", [3 * 3600]); assert.equal(Number((await rv("depositStatus", [[]]))[0]), 14); + // v10: the 24/7 redemption tag is always shown on Deposit (closed here, open below) and Redeem. + const hoursTag = "Redemptions are open 24/7. Deposit hours apply to deposits only."; await nav("Deposit"); await refresh(); await page.getByText(/^Deposits are closed until stock prices update \(at least 1 must have updated in the last 1 hour\(s\), e\.g\. a US market holiday\); no set time\. Redemptions are always open\.$/).waitFor(); + await page.getByText(hoursTag, { exact: true }).waitFor(); assert.equal(await page.getByText(/Deposits reopen /).count(), 0, "no reopen time on a holiday"); await page.getByRole("button", { name: "Add FIG", exact: true }).click(); await page.getByLabel("FIG amount", { exact: true }).fill("10"); @@ -878,6 +881,7 @@ try { // Same hash: reload so the holiday attempt's chosen stock is cleared. await page.reload(); await nav("Deposit"); + await page.getByText(hoursTag, { exact: true }).waitFor(); await page.getByRole("button", { name: "Add FIG", exact: true }).click(); await page .getByRole("button", { name: "Use full balance", exact: true }) @@ -969,6 +973,7 @@ try { await nav("Deposit"); await refresh(); await page.getByText(/^Deposits reopen Sunday 8:00 pm New York time \(Sunday, .* in \d+ h \d+ min; your time .*\)\. On a US market holiday they reopen when prices update\. Redemptions are always open\.$/).waitFor(); + await page.getByText(hoursTag, { exact: true }).waitFor(); await page.getByRole("button", { name: "Add FIG", exact: true }).click(); await page.getByLabel("FIG amount", { exact: true }).fill("1"); await page.getByRole("button", { name: "Preview deposit", exact: true }).click(); @@ -977,6 +982,11 @@ try { // Force a deferred leg, then claim it from the receiver wallet. await tx(stocks[0], compiled.TestStock.abi, "setFail", [true]); await nav("Redeem"); + await page.getByText(hoursTag, { exact: true }).waitFor(); + assert.equal( + await page.getByText("Receive your share of every stock.", { exact: true }).count(), + 1, + ); await page.getByLabel("BASK amount", { exact: true }).fill("10"); await page.getByLabel("Receiver", { exact: true }).fill(VAULT); await page.getByRole("button", { name: "Preview redemption" }).click(); @@ -1156,6 +1166,27 @@ try { .evaluate((e) => getComputedStyle(e).animationName), "none", ); + // v10: the strip's halves move as Web Animations; under reduced motion none exists (cancelled, not paused), no strip + // element has a CSS animation, and the halves stand still side by side from the window's left edge. + assert.equal( + await page.locator(".marquee").evaluate((m) => { + const w = m.querySelector(".marquee-window").getBoundingClientRect(), + h = [...m.querySelectorAll(".marquee-half")] + .map((e) => e.getBoundingClientRect()) + .sort((a, b) => a.left - b.left); + return [ + m.getAnimations({ subtree: true }).length, + [m, ...m.querySelectorAll("*")].filter( + (e) => getComputedStyle(e).animationName !== "none", + ).length, + h.length === 2 && + Math.abs(h[0].left - w.left) < 0.01 && + Math.abs(h[1].left - h[0].right) < 0.01 && + h[1].right >= w.right - 0.01, + ].join("/"); + }), + "0/0/true", + ); checks.push( "Six pages: desktop + 375px, 320px Docs reflow, keyboard focus, reduced motion", ); @@ -1183,6 +1214,19 @@ try { await shelfLabels.locator(".sa-chip").count(), await shelfLabels.count(), ); + // v10: no price time on the labels or in the details drawer ("Feed price": the price only). + const priceTime = /\bago\b|just now|price updated|price time|less than 1 min|\d+h \d+m/i; + assert.doesNotMatch(await page.locator(".stock-section").innerText(), priceTime); + await shelfLabels.first().locator("button.sa-more").click(); + const feedRow = page + .locator("#sa-drawer dl > div") + .filter({ has: page.locator("dt", { hasText: /^Feed price$/ }) }); + assert.match( + await feedRow.locator("dd").innerText(), + /^(\$[\d,]+(\.\d+)?|unreadable \/ invalid feed)$/, + ); + assert.doesNotMatch(await page.locator("#sa-drawer").innerText(), priceTime); + await page.locator("#sa-drawer").getByRole("button", { name: "Close", exact: true }).click(); failAggregate = false; checks.push("Aggregate failure fallback"); await raw("evm_increaseTime", [2 * 86400 + 5]); diff --git a/web/src/Docs.tsx b/web/src/Docs.tsx index 77f6279..3265653 100644 --- a/web/src/Docs.tsx +++ b/web/src/Docs.tsx @@ -19,7 +19,7 @@ export function DocsPage() {

Deposits

- deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US + Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open

diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx index 6ea68dc..ac548b7 100644 --- a/web/src/Flows.tsx +++ b/web/src/Flows.tsx @@ -35,6 +35,16 @@ import { import { DepositState } from "./Vault"; import type { Wallet } from "./wallet"; type Props = { snapshot: Snapshot; wallet: Wallet }; +// Always shown at the top of the Deposit and Redeem forms (open or closed), so +// nobody reads the deposit hours as redemption hours. +function HoursTag() { + return ( +

+ Redemptions are open 24/7.{" "} + Deposit hours apply to deposits only. +

+ ); +} export function DepositPage({ snapshot: s, wallet: w }: Props) { const open = s.assets.filter((a) => a.open && !a.retired); const [inputs, setInputs] = useState>({}); @@ -200,6 +210,7 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) {
+

Choose amounts, preview, approve each stock, press Deposit. @@ -571,10 +582,11 @@ export function RedeemPage({ snapshot: s, wallet: w }: Props) { line: "are you sure that’s all you came here for?", }} > - Receive your share of every stock. Redemption is always open. + Receive your share of every stock.

+

Redeem shares

Choose BASK and a receiver, preview, then press Redeem. The estimate diff --git a/web/src/Scenery.tsx b/web/src/Scenery.tsx index d1b5c7a..c623118 100644 --- a/web/src/Scenery.tsx +++ b/web/src/Scenery.tsx @@ -1,8 +1,75 @@ +import { useLayoutEffect, useRef } from "react"; + export function Marquee() { + const trackRef = useRef(null); + + useLayoutEffect(() => { + const halves = Array.from(trackRef.current!.children) as HTMLElement[]; + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); + const phone = matchMedia("(max-width: 660px)"); + let animations: Animation[] = []; + let width = 0; + let speed = 0; + + const update = () => { + const nextWidth = halves[0].getBoundingClientRect().width; + const nextSpeed = phone.matches ? 64 : 16; + if (reducedMotion.matches) { + animations.forEach((animation) => animation.cancel()); + animations = []; + return; + } + if ( + !nextWidth || + (animations.length && width === nextWidth && speed === nextSpeed) + ) return; + + // Keep the visible phase when resizing or when the web font arrives. + const distance = (Number(animations[0]?.currentTime ?? 0) * speed) / 1000; + animations.forEach((animation) => animation.cancel()); + width = nextWidth; + speed = nextSpeed; + const halfDuration = (width / speed) * 1000; + const elapsed = ((distance % width) / speed) * 1000; + const startTime = Number(document.timeline.currentTime) - elapsed; + + animations = halves.map((half, index) => { + // The two halves share a clock. Each wraps only outside the window; + // Web Animations don't dispatch CSS animationiteration events to React. + const animation = half.animate( + [ + { transform: "translateX(100%)" }, + { transform: "translateX(-100%)" }, + ], + { + duration: halfDuration * 2, + delay: index === 0 ? -halfDuration : 0, + iterations: Infinity, + easing: "linear", + }, + ); + animation.startTime = startTime; + return animation; + }); + }; + + const observer = new ResizeObserver(update); + observer.observe(halves[0]); + reducedMotion.addEventListener("change", update); + phone.addEventListener("change", update); + update(); + return () => { + observer.disconnect(); + reducedMotion.removeEventListener("change", update); + phone.removeEventListener("change", update); + animations.forEach((animation) => animation.cancel()); + }; + }, []); + return (