From cbad6029398f5dc8967ade0250c45c53372a2a67 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 13 Oct 2026 23:59:45 +0200 Subject: [PATCH 1/2] Add day-100 comparison to the official landing page --- CHANGELOG.md | 1 + .../assets/landing/js/comparison.js | 83 +++ .../assets/landing/js/sections.js | 4 + .../assets/landing/styles/comparison.css | 601 ++++++++++++++++++ .../assets/landing/styles/main.css | 1 + .../sqlpage/templates/landing-page.handlebars | 194 ++++++ tests/end-to-end/landing-comparison.spec.ts | 131 ++++ tests/end-to-end/landing-demos.spec.ts | 2 +- tests/end-to-end/landing-motion.spec.ts | 2 +- 9 files changed, 1017 insertions(+), 2 deletions(-) create mode 100644 examples/official-site/assets/landing/js/comparison.js create mode 100644 examples/official-site/assets/landing/styles/comparison.css create mode 100644 tests/end-to-end/landing-comparison.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f51ac2f..45bb7af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # CHANGELOG.md ## v0.47.0 (unreleased) +- Added a responsive, keyboard-accessible comparison of SQLPage and five alternative approaches to the official homepage. - The official homepage's database sculpture now scrolls natively with its section after the opening flight, and section layouts reserve its space with CSS grid. - For sites that store their pages in a database, checking for page updates no longer makes other pages wait when the database is busy. In a test, another page loaded in under 1 ms instead of about 250 ms. - Redesigned the official website homepage with live SQLPage examples, an interactive database sculpture, flowing section transitions, responsive layouts, and direct links to documentation and deployment guides. diff --git a/examples/official-site/assets/landing/js/comparison.js b/examples/official-site/assets/landing/js/comparison.js new file mode 100644 index 00000000..116cb679 --- /dev/null +++ b/examples/official-site/assets/landing/js/comparison.js @@ -0,0 +1,83 @@ +/** Native scrolling remains available without enhancement, including on touch screens. */ +export function initComparison(root, signal) { + const section = root.querySelector(".comparison-section"); + const track = section.querySelector(".comparison-track"); + const cards = [...track.children]; + const previous = section.querySelector("[data-comparison-previous]"); + const next = section.querySelector("[data-comparison-next]"); + const status = section.querySelector("[data-comparison-status]"); + const motion = matchMedia("(prefers-reduced-motion: reduce)"); + + function positions() { + // Layout offsets stay accurate while section reveals scale the painted track. + const origin = cards[0].offsetLeft; + return cards.map((card) => card.offsetLeft - origin); + } + + function update() { + const offsets = positions(); + const first = offsets.findIndex( + (left, index) => left + cards[index].offsetWidth > track.scrollLeft + 2, + ); + const last = offsets.findLastIndex( + (left) => left < track.scrollLeft + track.clientWidth - 2, + ); + previous.disabled = track.scrollLeft <= 2; + next.disabled = + track.scrollLeft >= track.scrollWidth - track.clientWidth - 2; + const range = + first === last ? String(first + 1) : `${first + 1}–${last + 1}`; + const label = `${range} of ${cards.length} alternatives`; + // Avoid repeating live announcements during every frame of a smooth scroll. + if (status.textContent !== label) status.textContent = label; + } + + function goTo(left) { + track.scrollTo({ + left, + behavior: motion.matches ? "instant" : "smooth", + }); + } + + function move(direction) { + const offsets = positions(); + const target = + direction > 0 + ? offsets.find((left) => left > track.scrollLeft + 2) + : offsets.findLast((left) => left < track.scrollLeft - 2); + goTo(target ?? (direction > 0 ? track.scrollWidth : 0)); + } + + previous.addEventListener("click", () => move(-1), { signal }); + next.addEventListener("click", () => move(1), { signal }); + track.addEventListener("scroll", update, { passive: true, signal }); + track.addEventListener( + "keydown", + (event) => { + if (event.target !== track) return; + switch (event.key) { + case "ArrowLeft": + move(-1); + break; + case "ArrowRight": + move(1); + break; + case "Home": + goTo(0); + break; + case "End": + goTo(track.scrollWidth); + break; + default: + return; + } + event.preventDefault(); + }, + { signal }, + ); + const observer = new ResizeObserver(update); + observer.observe(track); + section.querySelector(".comparison-controls").hidden = false; + update(); + return () => observer.disconnect(); +} diff --git a/examples/official-site/assets/landing/js/sections.js b/examples/official-site/assets/landing/js/sections.js index d2585926..5bc24862 100644 --- a/examples/official-site/assets/landing/js/sections.js +++ b/examples/official-site/assets/landing/js/sections.js @@ -1,4 +1,6 @@ /** Wire section controls; motion and live-preview loading live in separate modules. */ + +import { initComparison } from "./comparison.js"; import { initComponentDemos } from "./component-demos.js"; import { initTabs } from "./tabs.js"; @@ -6,6 +8,7 @@ export function initSections(root) { const events = new AbortController(); const { signal } = events; const cleanupDemos = initComponentDemos(root, signal); + const cleanupComparison = initComparison(root, signal); initTabs( root.querySelector('[aria-label="Application stack"]'), "aria-pressed", @@ -36,5 +39,6 @@ export function initSections(root) { return () => { events.abort(); cleanupDemos(); + cleanupComparison(); }; } diff --git a/examples/official-site/assets/landing/styles/comparison.css b/examples/official-site/assets/landing/styles/comparison.css new file mode 100644 index 00000000..bad7be06 --- /dev/null +++ b/examples/official-site/assets/landing/styles/comparison.css @@ -0,0 +1,601 @@ +/* Day 100: a fixed SQLPage example beside a natively scrollable set of alternatives. */ +.comparison-section { + --section-color: var(--brand-paper); + --comparison-border: #dbe2e7; + --comparison-copy: #49545f; + --comparison-muted: #626f7c; + background: var(--section-color); + color: #0b1020; + color-scheme: light; + padding-bottom: calc(var(--wave-height) + 64px); +} +.comparison-section .eyebrow { + color: #146575; +} +.comparison-section h2 em { + color: #0e8292; +} +.comparison-section .section-heading { + grid-template-columns: minmax(0, 1.35fr) minmax(0, 0.95fr) minmax(0, 0.27fr); + margin-bottom: 44px; +} +.comparison-section h2 { + font-size: clamp(64px, 7.2vw, 102px); +} +.comparison-intro { + align-self: end; + margin: 0 0 8px; + font-size: clamp(19px, 1.7vw, 24px); + line-height: 1.4; + color: var(--comparison-copy); +} +.comparison-layout { + display: grid; + grid-template-columns: minmax(0, 1.3fr) 36px minmax(0, 2fr); + gap: 18px; + padding-top: 34px; + border-top: 1px solid #cbd4da; +} +.comparison-card { + min-width: 0; +} +.comparison-card-heading { + display: flex; + align-items: center; + gap: 14px; + min-height: 62px; + margin-bottom: 14px; +} +.comparison-card-heading > svg { + width: 26px; + height: 26px; + flex-shrink: 0; +} +.comparison-card-heading img { + object-fit: contain; + flex-shrink: 0; +} +.comparison-card-heading h3 { + margin: 0; + font-size: clamp(21px, 2vw, 28px); + line-height: 1.15; + font-weight: 700; + letter-spacing: -0.025em; +} +.comparison-card-heading p { + margin: 5px 0 0; + color: var(--comparison-muted); + font: 11px / 1.5 var(--mono); +} +.comparison-versus { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + margin: 76px 0 115px; + color: #8a96a2; + font: 500 26px var(--brand-font); +} +.comparison-versus::before, +.comparison-versus::after { + content: ""; + width: 1px; + flex: 1; + background: #cbd4da; +} +.comparison-alternatives { + min-width: 0; +} +.comparison-track { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: calc((100% - 24px) / 2); + gap: 24px; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + scrollbar-width: thin; + scrollbar-color: #9caeb5 transparent; + padding-bottom: 15px; +} +.comparison-track > .comparison-card { + scroll-snap-align: start; +} +.comparison-track:focus-visible { + outline-offset: 4px; +} +.comparison-window { + height: 326px; + border: 1px solid var(--comparison-border); + border-radius: 12px; + background: #fff; + color: #242b32; + overflow: hidden; + box-shadow: 0 12px 24px #18262d0a; +} +.comparison-window svg { + width: 15px; + height: 15px; + flex-shrink: 0; + vertical-align: middle; +} +.comparison-code { + border-color: #202739; + background: #080d19; + color: #eef1f7; +} +.comparison-window-bar { + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 14px; + background: #f6f8fa; + border-bottom: 1px solid var(--comparison-border); + font: 11px / 1.4 var(--mono); +} +.comparison-window-bar > span:first-child { + display: flex; + align-items: center; + gap: 8px; +} +.comparison-window-bar > span:last-child:not(:first-child) { + opacity: 0.55; +} +.comparison-code .comparison-window-bar { + background: #171d29; + border-color: #303746; +} +.comparison-window-body { + display: flex; + flex-direction: column; + height: calc(100% - 36px); + padding: 18px 16px 14px; +} +.comparison-window h4 { + margin: 0 0 12px; + font-size: 23px; + line-height: 1.2; + letter-spacing: -0.02em; +} +.comparison-diff { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0 0 12px; + color: #97a8c3; + font: 11px / 1.4 var(--mono); +} +.comparison-added { + color: #5cde93; +} +.comparison-removed { + color: #ff797e; +} +.comparison-diff i { + width: 56px; + height: 8px; + background: + repeating-linear-gradient(90deg, transparent 0 7px, #080d19 7px 9px), + linear-gradient(90deg, #48bb70 82%, #f15b62 82%); +} +.comparison-files { + margin: 0; + padding: 0; + list-style: none; + font: 11px / 1.5 var(--mono); +} +.comparison-files li { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 7px 0; + border-top: 1px solid #ffffff1c; +} +.comparison-files li > span:first-child { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow-wrap: anywhere; +} +.comparison-files li > span:last-child { + white-space: nowrap; +} +.comparison-files svg { + opacity: 0.55; +} +.comparison-files b { + font-weight: 400; +} +.comparison-deploy { + display: flex; + align-items: center; + gap: 10px; + margin: auto 0 0; + padding-top: 14px; + border-top: 1px solid #ffffff1c; + font: 12px / 1.4 var(--mono); +} +.comparison-deploy svg { + color: #29d6df; + width: 18px; + height: 18px; +} +.comparison-deploy span { + color: #97a8c3; +} +.comparison-more { + margin: 5px 0; + font: 11px / 1.5 var(--mono); + color: #97a8b8; +} +.comparison-checks { + margin-top: auto; + font: 11px / 1.5 var(--mono); +} +.comparison-checks p { + margin: 4px 0 0; + color: #5cde93; +} +.comparison-checks p:first-child { + color: #f5c457; +} +.comparison-card .comparison-verdict { + margin: 18px 0 8px; + font-size: 21px; + line-height: 1.2; + font-weight: 700; + letter-spacing: -0.025em; +} +.comparison-sqlpage .comparison-verdict { + font-size: clamp(23px, 2.2vw, 29px); +} +.comparison-description { + margin: 0; + color: var(--comparison-copy); + font-size: 16px; + line-height: 1.5; +} +.comparison-card .comparison-aside { + margin: 10px 0 0; + color: var(--comparison-muted); + font-size: 11px; +} +.comparison-sheet .comparison-window-body { + padding: 0 12px 12px; +} +.comparison-sheet-labels, +.comparison-sheet-files li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.comparison-sheet-labels { + padding: 12px 0 8px; + font: 10px var(--mono); + color: var(--comparison-muted); +} +.comparison-sheet-files { + list-style: none; + margin: 0 -12px; + padding: 0; + font-size: 13px; +} +.comparison-sheet-files li { + padding: 9px 12px; +} +.comparison-sheet-files li:nth-child(4) { + background: #eaf1ff; +} +.comparison-sheet-files li > span { + display: flex; + align-items: center; + gap: 8px; +} +.comparison-sheet-files b { + display: grid; + place-items: center; + width: 16px; + height: 18px; + border-radius: 3px; + background: #217747; + color: white; + font-size: 11px; +} +.comparison-sheet-files small { + font: 10px var(--mono); + color: var(--comparison-muted); + white-space: nowrap; +} +.comparison-lock { + display: flex; + align-items: center; + gap: 8px; + margin: auto 0 0; + padding: 10px; + border: 1px solid #eed3a0; + border-radius: 7px; + background: #fbf5e9; + color: #795100; + font-size: 12px; +} +.comparison-terminal { + padding-top: 20px; + font: 11px / 1.75 var(--mono); +} +.comparison-terminal p { + margin: 0; + overflow-wrap: anywhere; +} +.comparison-terminal p > span:not(.comparison-more) { + color: #5cde93; +} +.comparison-cursor { + display: inline-block; + width: 7px; + height: 13px; + background: #97a8b8; + vertical-align: middle; +} +.comparison-metric { + display: flex; + justify-content: space-between; + gap: 8px; +} +.comparison-metric strong { + display: block; + font-size: 30px; + line-height: 1.1; +} +.comparison-metric small, +.comparison-chat small { + display: block; + font-size: 13px; + color: var(--comparison-muted); + margin-top: 4px; +} +.comparison-metric .comparison-view-only { + padding: 5px 10px; + border: 1px solid var(--comparison-border); + border-radius: 5px; + align-self: start; + position: relative; + color: #6e7c89; + background: #f6f8fa; + font-size: 12px; +} +.comparison-metric .comparison-view-only small { + position: absolute; + top: 100%; + right: 0; + white-space: nowrap; + border-radius: 4px; + padding: 6px 8px; + background: #242b32; + color: white; + font-size: 10px; +} +.comparison-bars { + display: flex; + align-items: end; + gap: 4px; + height: 105px; + padding-top: 15px; + margin-top: 20px; + border-bottom: 1px solid var(--comparison-border); +} +.comparison-bars i { + flex: 1; + height: 42%; + background: #adc1ff; + border-radius: 2px 2px 0 0; +} +.comparison-bars i:nth-child(3n) { + height: 58%; +} +.comparison-bars i:nth-child(3n + 1) { + height: 78%; +} +.comparison-bars i:nth-child(4n) { + height: 65%; +} +.comparison-bars i:first-child { + height: 40%; +} +.comparison-bars i:nth-child(5n) { + height: 88%; +} +.comparison-bars i:last-child { + height: 100%; + background: #326cff; +} +.comparison-chat { + display: flex; + align-items: start; + gap: 10px; + margin: auto 0 0; + font-size: 14px; +} +.comparison-chat b { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: #326cff; + color: white; + font-size: 12px; +} +.comparison-lowcode .comparison-window-body { + position: relative; + background: #e0e4e8; +} +.comparison-lowcode table { + border-collapse: collapse; + font-size: 12px; + width: 100%; + color: #626b74; +} +.comparison-lowcode th { + font: 10px var(--mono); + text-align: left; +} +.comparison-lowcode td, +.comparison-lowcode th { + padding: 7px 0; + border-bottom: 1px solid #cbd4da; +} +.comparison-lowcode td:last-child, +.comparison-lowcode th:last-child { + text-align: right; +} +.comparison-lowcode .comparison-window-body::after { + content: ""; + position: absolute; + inset: 0; + background: #18262d40; +} +.comparison-upgrade { + position: absolute; + z-index: 1; + top: 50%; + left: 16px; + right: 16px; + transform: translateY(-50%); + padding: 20px 16px; + border-radius: 12px; + background: #fff; + box-shadow: 0 15px 30px #18262d40; + font-size: 16px; +} +.comparison-upgrade p { + margin: 5px 0 20px; + font-size: 13px; + color: var(--comparison-copy); + line-height: 1.4; +} +.comparison-upgrade > div { + display: flex; + align-items: center; + justify-content: end; + gap: 12px; + font-size: 12px; + color: var(--comparison-muted); +} +.comparison-upgrade > div span:last-child { + border-radius: 6px; + padding: 8px 12px; + background: #6844df; + color: white; +} +.comparison-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 30px; + margin-top: 36px; +} +.comparison-footer > p { + margin: 0; + color: var(--comparison-muted); + font-size: 11px; + max-width: 720px; +} +.comparison-section .comparison-controls { + /* Focus must not move an arrow between pointer-down and click during an outro. */ + --reveal-enter-x: 0px; + --reveal-exit-x: 0px; + --reveal-enter-y: 0px; + --reveal-exit-y: 0px; + --reveal-enter-scale: 1; + --reveal-exit-scale: 1; + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} +.comparison-controls p { + margin: 0 10px 0 0; + color: var(--comparison-muted); + font: 11px / 1.5 var(--mono); +} +.comparison-controls button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid #a8b5bf; + border-radius: 8px; + background: transparent; + color: #0b1020; + transition: + background 200ms, + border-color 200ms; +} +.comparison-controls button:not(:disabled):hover { + background: #e1ecef; + border-color: #146575; +} +.comparison-controls svg { + width: 20px; + height: 20px; +} +@media (max-width: 1100px) { + .comparison-section .section-heading { + grid-template-columns: minmax(0, 1fr) minmax(0, 0.17fr); + } + .comparison-intro { + grid-column: 1 / -1; + order: 1; + margin: 0; + } + .comparison-layout { + grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr); + } + .comparison-track { + grid-auto-columns: 100%; + } +} +@media (max-width: 700px) { + .comparison-layout { + grid-template-columns: minmax(0, 1fr); + gap: 20px; + padding-top: 20px; + } + .comparison-versus { + flex-direction: row; + margin: 4px 0; + gap: 16px; + font-size: 20px; + } + .comparison-versus::before, + .comparison-versus::after { + height: 1px; + } + .comparison-intro { + font-size: 19px; + } + .comparison-footer { + flex-direction: column-reverse; + align-items: start; + gap: 24px; + margin-top: 20px; + } + .comparison-controls { + width: 100%; + } + .comparison-controls p { + margin-right: auto; + } +} +@media (max-width: 600px) { + .comparison-section h2 { + font-size: clamp(34px, 10.5vw, 63px); + } +} diff --git a/examples/official-site/assets/landing/styles/main.css b/examples/official-site/assets/landing/styles/main.css index bd2e68c4..839bde8d 100644 --- a/examples/official-site/assets/landing/styles/main.css +++ b/examples/official-site/assets/landing/styles/main.css @@ -14,4 +14,5 @@ @import url("./component-demos.css"); @import url("./waves.css"); @import url("./frontend.css"); +@import url("./comparison.css"); @import url("./section-motion.css"); diff --git a/examples/official-site/sqlpage/templates/landing-page.handlebars b/examples/official-site/sqlpage/templates/landing-page.handlebars index ef9b0ffa..51f9a3e6 100644 --- a/examples/official-site/sqlpage/templates/landing-page.handlebars +++ b/examples/official-site/sqlpage/templates/landing-page.handlebars @@ -339,6 +339,200 @@ +
+ {{> landing-wave}} +
+

-- one refunds tool, six ways to build it, on day 100

+
+

Low Effort.
Low Drama.

+

Building got cheap.
Owning what got built is still expensive.

+
+
+
+
+
+ +
+

SQLPage

+

SQL files + your database

+
+
+
+
+ {{icon_img "git-branch"}} refunds-page + #42 +
+
+

Add refunds page

+

3 files changed +26 −2

+
    +
  • {{icon_img "file"}} refunds.sql+14
  • +
  • {{icon_img "file"}} index.sql+4 −2
  • +
  • {{icon_img "file"}} sqlpage/migrations/0007_refunds.sql+8
  • +
+

{{icon_img "circle-check"}} git push → live

+
+
+

Day 100: still a few files you can read.

+

SQL files in git. One stateless binary. The common attacks handled by default.

+

-- boring on purpose.

+
+ +
+

Scroll horizontally to explore five illustrative alternatives. Use the left and right arrow keys when this region is focused.

+
+
+
+ {{icon_img "sparkles"}} +
+

Agent-built app

+

An AI agent writes the full stack

+
+
+
+
+ {{icon_img "git-branch"}} refunds-page + #318 +
+
+

Add refunds page

+

214 files +8,402 −1,317

+
    +
  • {{icon_img "file"}} package-lock.json+6,120
  • +
  • {{icon_img "file"}} src/components/RefundTable.tsx+212
  • +
  • {{icon_img "file"}} src/api/refunds.ts+148
  • +
  • {{icon_img "file"}} prisma/schema.prisma+31
  • +
+

… and 209 more files

+
+

{{icon_img "alert-triangle"}} 2 dependency alerts

+

{{icon_img "check"}} 1 approval: LGTM

+
+
+
+

The vibe debt comes due.

+

-- LGTM (nobody read it)

+
+
+
+ {{icon_img "table"}} +
+

Spreadsheet

+

Excel, Google Sheets

+
+
+
+
+ Refunds + 5 items +
+
+
NameModified ↓
+
    +
  • X refunds.xlsxMar 3
  • +
  • X refunds_final.xlsxMar 9
  • +
  • X refunds_final_v2.xlsxMar 9
  • +
  • X refunds_FINAL_real.xlsxApr 2
  • +
  • X refunds (1).xlsxJun 1
  • +
+

{{icon_img "lock"}} Locked for editing by dave

+
+
+

Great until the data drifts.

+

-- which one is real?

+
+
+
+ {{icon_img "terminal-2"}} +
+

Custom scripts

+

Python, shell, cron

+
+
+
+
+ zsh + ~/reports +
+
+

$ crontab -l

+

0 6 * * * python3 ~/dave/refunds.py

+

$ tail -n3 refunds.log

+

05-14 06:00  ok
05-15 06:00  ok

+

05-16 06:00  ConnectionError

+

$ git log -1 --format=%an

+

dave (left in March)

+

$

+
+
+

Works. Until the author leaves.

+

-- cron says it ran

+
+
+
+ {{icon_img "chart-bar"}} +
+

BI dashboard

+

Charts on top of your data

+
+
+
+
Refunds · dashboard
+
+
+
€12.4krefunded, last 30 days
+ Edit View-only access +
+ +

SCan we add a button?Not in this tool.

+
+
+

Great for charts. Not for actions.

+

-- look, but don't touch

+
+
+
+ {{icon_img "apps"}} +
+

Low-code platform

+

Drag-and-drop app builders

+
+
+
+
Refunds app · editor
+
+ + + + + + + + +
OrderCustomerAmount
#1042Ada L.€48.00
#1043Grace H.€12.50
#1044Linus T.€76.00
#1045Margaret H.€24.00
+
+ Export is a Business feature +

Upgrade to export refunds as CSV.

+
Not nowSee plans
+
+
+
+

It lives on their terms.

+

-- 14 seats, billed monthly

+
+
+
+
+ +
+
{{> landing-wave}}
diff --git a/tests/end-to-end/landing-comparison.spec.ts b/tests/end-to-end/landing-comparison.spec.ts new file mode 100644 index 00000000..3ded5416 --- /dev/null +++ b/tests/end-to-end/landing-comparison.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from "@playwright/test"; + +for (const settings of [ + { width: 1440, height: 1000, reducedMotion: "no-preference", visible: 2 }, + { width: 768, height: 1024, reducedMotion: "reduce", visible: 1 }, + { width: 390, height: 844, reducedMotion: "reduce", visible: 1 }, +] as const) { + test(`landing comparison browses all alternatives at ${settings.width}px`, async ({ + page, + }) => { + await page.setViewportSize(settings); + await page.emulateMedia({ reducedMotion: settings.reducedMotion }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + expect( + await page.locator(".landing-section").evaluateAll((sections) => { + const comparison = sections.findIndex( + (section) => section.id === "compare", + ); + const databases = sections.findIndex((section) => + section.classList.contains("databases-section"), + ); + return comparison + 1 === databases; + }), + ).toBe(true); + const section = page.locator("#compare"); + const track = section.getByRole("region"); + const previous = section.getByRole("button", { + name: "Previous alternative", + }); + const next = section.getByRole("button", { name: "Next alternative" }); + const status = section.getByRole("status"); + await next.scrollIntoViewIfNeeded(); + await expect(previous).toBeDisabled(); + await expect(status).toHaveText( + settings.visible === 2 ? "1–2 of 5 alternatives" : "1 of 5 alternatives", + ); + const sqlpage = section.locator(".comparison-sqlpage"); + const initialHeight = await section.evaluate( + (section) => section.clientHeight, + ); + for (let index = 1; index <= 5 - settings.visible; index++) { + await next.click(); + await expect(status).toHaveText( + settings.visible === 2 + ? `${index + 1}–${index + 2} of 5 alternatives` + : `${index + 1} of 5 alternatives`, + ); + await expect( + sqlpage.getByRole("heading", { name: "SQLPage", exact: true }), + ).toBeVisible(); + } + await expect(next).toBeDisabled(); + await expect(previous).toBeEnabled(); + // The final card is wholly readable, and cycling never changes section geometry. + const last = track.locator(".comparison-card").last(); + expect( + await last.evaluate((card) => { + const bounds = card.getBoundingClientRect(); + const track = card.parentElement?.getBoundingClientRect(); + if (!track) throw new Error("Missing alternatives track"); + return bounds.left >= track.left - 1 && bounds.right <= track.right + 1; + }), + ).toBe(true); + expect(await section.evaluate((section) => section.clientHeight)).toBe( + initialHeight, + ); + await track.focus(); + await track.press("Home"); + await expect(previous).toBeDisabled(); + await track.press("ArrowLeft"); + await expect(previous).toBeDisabled(); + await track.press("ArrowRight"); + await expect(previous).toBeEnabled(); + await track.press("End"); + await expect(next).toBeDisabled(); + await track.press("ArrowRight"); + await expect(next).toBeDisabled(); + await track.press("ArrowLeft"); + await expect(next).toBeEnabled(); + await previous.click(); + await expect(next).toBeEnabled(); + // Resize the same page after browsing; scroll bounds and controls update together. + await page.setViewportSize({ width: 1280, height: 900 }); + await track.focus(); + await track.press("End"); + await expect(status).toHaveText("4–5 of 5 alternatives"); + await expect(next).toBeDisabled(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + expect(errors).toEqual([]); + }); +} + +test("landing comparison remains browsable without JavaScript", async ({ + browser, + baseURL, +}) => { + const context = await browser.newContext({ + baseURL, + javaScriptEnabled: false, + viewport: { width: 390, height: 844 }, + }); + const page = await context.newPage(); + await page.goto("/"); + const section = page.locator("#compare"); + await section.scrollIntoViewIfNeeded(); + await expect( + section.getByRole("group", { name: "Browse alternatives" }), + ).toBeHidden(); + const track = section.getByRole("region"); + await track.scrollIntoViewIfNeeded(); + await track.hover(); + await page.mouse.wheel(3000, 0); + await expect + .poll(() => track.evaluate((track) => track.scrollLeft)) + .toBeGreaterThan(0); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await expect( + section.getByRole("heading", { name: "Low-code platform", exact: true }), + ).toBeVisible(); + await context.close(); +}); diff --git a/tests/end-to-end/landing-demos.spec.ts b/tests/end-to-end/landing-demos.spec.ts index 259c60fb..a9ba0fa1 100644 --- a/tests/end-to-end/landing-demos.spec.ts +++ b/tests/end-to-end/landing-demos.spec.ts @@ -170,7 +170,7 @@ test("a failed landing example keeps the current preview and SQL", async ({ await page .getByRole("tab", { name: "chart A timeline from 3 rows", exact: true }) .click(); - await expect(page.getByRole("status")).toHaveText( + await expect(page.locator("#demo-panel").getByRole("status")).toHaveText( "The demo could not load. Please try again.", ); await expect(demo.getByRole("cell", { name: "Acme Corp" })).toBeVisible(); diff --git a/tests/end-to-end/landing-motion.spec.ts b/tests/end-to-end/landing-motion.spec.ts index 96e19acd..db5b859b 100644 --- a/tests/end-to-end/landing-motion.spec.ts +++ b/tests/end-to-end/landing-motion.spec.ts @@ -231,7 +231,7 @@ for (const viewport of [ // Even the last card gets a fully readable interval before the next wave arrives. const purpose = requireValue(positions[1], "purpose section"); const deployment = requireValue(positions[2], "deployment section"); - const closing = requireValue(positions[4], "closing section"); + const closing = requireValue(positions.at(-1), "closing section"); const readingScroll = purpose.top - purpose.stickyTop; await scrollTo(Math.ceil(readingScroll)); await expect(trailingCard).toHaveCSS("opacity", "1"); From 9325d7207683917e5dde8d52b93672e7572b8ed9 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Wed, 14 Oct 2026 00:11:22 +0200 Subject: [PATCH 2/2] Share comparison card markup and presentation primitives --- .../assets/landing/js/comparison.js | 44 ++--- .../assets/landing/styles/comparison.css | 48 +----- .../sqlpage/templates/landing-page.handlebars | 157 +++++++----------- tests/end-to-end/landing-comparison.spec.ts | 36 ++-- 4 files changed, 93 insertions(+), 192 deletions(-) diff --git a/examples/official-site/assets/landing/js/comparison.js b/examples/official-site/assets/landing/js/comparison.js index 116cb679..507037d8 100644 --- a/examples/official-site/assets/landing/js/comparison.js +++ b/examples/official-site/assets/landing/js/comparison.js @@ -8,14 +8,8 @@ export function initComparison(root, signal) { const status = section.querySelector("[data-comparison-status]"); const motion = matchMedia("(prefers-reduced-motion: reduce)"); - function positions() { - // Layout offsets stay accurate while section reveals scale the painted track. - const origin = cards[0].offsetLeft; - return cards.map((card) => card.offsetLeft - origin); - } - function update() { - const offsets = positions(); + const offsets = cards.map((card) => card.offsetLeft); const first = offsets.findIndex( (left, index) => left + cards[index].offsetWidth > track.scrollLeft + 2, ); @@ -40,38 +34,30 @@ export function initComparison(root, signal) { } function move(direction) { - const offsets = positions(); - const target = + // Equal-width grid columns snap one card at a time; native scrolling clamps the ends. + const step = cards[1].offsetLeft; + const index = direction > 0 - ? offsets.find((left) => left > track.scrollLeft + 2) - : offsets.findLast((left) => left < track.scrollLeft - 2); - goTo(target ?? (direction > 0 ? track.scrollWidth : 0)); + ? Math.floor((track.scrollLeft + 2) / step) + 1 + : Math.ceil((track.scrollLeft - 2) / step) - 1; + goTo(index * step); } previous.addEventListener("click", () => move(-1), { signal }); next.addEventListener("click", () => move(1), { signal }); track.addEventListener("scroll", update, { passive: true, signal }); + const keys = { + ArrowLeft: () => move(-1), + ArrowRight: () => move(1), + Home: () => goTo(0), + End: () => goTo(track.scrollWidth), + }; track.addEventListener( "keydown", (event) => { - if (event.target !== track) return; - switch (event.key) { - case "ArrowLeft": - move(-1); - break; - case "ArrowRight": - move(1); - break; - case "Home": - goTo(0); - break; - case "End": - goTo(track.scrollWidth); - break; - default: - return; - } + if (event.target !== track || !keys[event.key]) return; event.preventDefault(); + keys[event.key](); }, { signal }, ); diff --git a/examples/official-site/assets/landing/styles/comparison.css b/examples/official-site/assets/landing/styles/comparison.css index bad7be06..8853a8ec 100644 --- a/examples/official-site/assets/landing/styles/comparison.css +++ b/examples/official-site/assets/landing/styles/comparison.css @@ -1,3 +1,9 @@ +/* Shared row primitive for the static illustrations and section controls. */ +.comparison-row { + display: flex; + align-items: center; + gap: 8px; +} /* Day 100: a fixed SQLPage example beside a natively scrollable set of alternatives. */ .comparison-section { --section-color: var(--brand-paper); @@ -40,8 +46,6 @@ min-width: 0; } .comparison-card-heading { - display: flex; - align-items: center; gap: 14px; min-height: 62px; margin-bottom: 14px; @@ -68,9 +72,7 @@ font: 11px / 1.5 var(--mono); } .comparison-versus { - display: flex; flex-direction: column; - align-items: center; gap: 20px; margin: 76px 0 115px; color: #8a96a2; @@ -127,8 +129,6 @@ } .comparison-window-bar { height: 36px; - display: flex; - align-items: center; justify-content: space-between; gap: 10px; padding: 0 14px; @@ -136,11 +136,6 @@ border-bottom: 1px solid var(--comparison-border); font: 11px / 1.4 var(--mono); } -.comparison-window-bar > span:first-child { - display: flex; - align-items: center; - gap: 8px; -} .comparison-window-bar > span:last-child:not(:first-child) { opacity: 0.55; } @@ -161,10 +156,7 @@ letter-spacing: -0.02em; } .comparison-diff { - display: flex; flex-wrap: wrap; - align-items: center; - gap: 8px; margin: 0 0 12px; color: #97a8c3; font: 11px / 1.4 var(--mono); @@ -189,15 +181,12 @@ font: 11px / 1.5 var(--mono); } .comparison-files li { - display: flex; + align-items: normal; justify-content: space-between; - gap: 8px; padding: 7px 0; border-top: 1px solid #ffffff1c; } .comparison-files li > span:first-child { - display: flex; - align-items: center; gap: 7px; min-width: 0; overflow-wrap: anywhere; @@ -212,8 +201,6 @@ font-weight: 400; } .comparison-deploy { - display: flex; - align-items: center; gap: 10px; margin: auto 0 0; padding-top: 14px; @@ -270,10 +257,7 @@ } .comparison-sheet-labels, .comparison-sheet-files li { - display: flex; - align-items: center; justify-content: space-between; - gap: 8px; } .comparison-sheet-labels { padding: 12px 0 8px; @@ -292,11 +276,6 @@ .comparison-sheet-files li:nth-child(4) { background: #eaf1ff; } -.comparison-sheet-files li > span { - display: flex; - align-items: center; - gap: 8px; -} .comparison-sheet-files b { display: grid; place-items: center; @@ -313,9 +292,6 @@ white-space: nowrap; } .comparison-lock { - display: flex; - align-items: center; - gap: 8px; margin: auto 0 0; padding: 10px; border: 1px solid #eed3a0; @@ -343,9 +319,8 @@ vertical-align: middle; } .comparison-metric { - display: flex; + align-items: normal; justify-content: space-between; - gap: 8px; } .comparison-metric strong { display: block; @@ -415,7 +390,6 @@ background: #326cff; } .comparison-chat { - display: flex; align-items: start; gap: 10px; margin: auto 0 0; @@ -480,8 +454,6 @@ line-height: 1.4; } .comparison-upgrade > div { - display: flex; - align-items: center; justify-content: end; gap: 12px; font-size: 12px; @@ -494,8 +466,6 @@ color: white; } .comparison-footer { - display: flex; - align-items: center; justify-content: space-between; gap: 30px; margin-top: 36px; @@ -514,8 +484,6 @@ --reveal-exit-y: 0px; --reveal-enter-scale: 1; --reveal-exit-scale: 1; - display: flex; - align-items: center; gap: 10px; flex-shrink: 0; } diff --git a/examples/official-site/sqlpage/templates/landing-page.handlebars b/examples/official-site/sqlpage/templates/landing-page.handlebars index 51f9a3e6..c0bb37fc 100644 --- a/examples/official-site/sqlpage/templates/landing-page.handlebars +++ b/examples/official-site/sqlpage/templates/landing-page.handlebars @@ -28,6 +28,18 @@ {{/inline}} + {{#*inline "comparison-card"}} +
+
+ {{#if sqlpage}}{{else}}{{icon_img glyph}}{{/if}} +

{{title}}

{{caption}}

+
+ {{> @partial-block}} +

{{verdict}}

+ {{#if summary}}

{{summary}}

{{/if}} +

{{aside}}

+
+ {{/inline}}
@@ -349,59 +361,42 @@
-
-
- -
-

SQLPage

-

SQL files + your database

-
-
+ {{#> comparison-card id="compare-sqlpage" title="SQLPage" caption="SQL files + your database" verdict="Day 100: still a few files you can read." aside="-- boring on purpose." sqlpage=true summary="SQL files in git. One stateless binary. The common attacks handled by default."}}
-
- {{icon_img "git-branch"}} refunds-page +
+ {{icon_img "git-branch"}} refunds-page #42

Add refunds page

-

3 files changed +26 −2

+

3 files changed +26 −2

    -
  • {{icon_img "file"}} refunds.sql+14
  • -
  • {{icon_img "file"}} index.sql+4 −2
  • -
  • {{icon_img "file"}} sqlpage/migrations/0007_refunds.sql+8
  • +
  • {{icon_img "file"}} refunds.sql+14
  • +
  • {{icon_img "file"}} index.sql+4 −2
  • +
  • {{icon_img "file"}} sqlpage/migrations/0007_refunds.sql+8
-

{{icon_img "circle-check"}} git push → live

+

{{icon_img "circle-check"}} git push → live

-

Day 100: still a few files you can read.

-

SQL files in git. One stateless binary. The common attacks handled by default.

-

-- boring on purpose.

-
- + {{/comparison-card}} +

Scroll horizontally to explore five illustrative alternatives. Use the left and right arrow keys when this region is focused.

-
-
- {{icon_img "sparkles"}} -
-

Agent-built app

-

An AI agent writes the full stack

-
-
+ {{#> comparison-card id="compare-agent" title="Agent-built app" caption="An AI agent writes the full stack" verdict="The vibe debt comes due." aside="-- LGTM (nobody read it)" glyph="sparkles"}}
-
- {{icon_img "git-branch"}} refunds-page +
+ {{icon_img "git-branch"}} refunds-page #318

Add refunds page

-

214 files +8,402 −1,317

+

214 files +8,402 −1,317

    -
  • {{icon_img "file"}} package-lock.json+6,120
  • -
  • {{icon_img "file"}} src/components/RefundTable.tsx+212
  • -
  • {{icon_img "file"}} src/api/refunds.ts+148
  • -
  • {{icon_img "file"}} prisma/schema.prisma+31
  • +
  • {{icon_img "file"}} package-lock.json+6,120
  • +
  • {{icon_img "file"}} src/components/RefundTable.tsx+212
  • +
  • {{icon_img "file"}} src/api/refunds.ts+148
  • +
  • {{icon_img "file"}} prisma/schema.prisma+31

… and 209 more files

@@ -410,48 +405,30 @@
-

The vibe debt comes due.

-

-- LGTM (nobody read it)

-
-
-
- {{icon_img "table"}} -
-

Spreadsheet

-

Excel, Google Sheets

-
-
+ {{/comparison-card}} + {{#> comparison-card id="compare-spreadsheet" title="Spreadsheet" caption="Excel, Google Sheets" verdict="Great until the data drifts." aside="-- which one is real?" glyph="table"}}
-
- Refunds +
+ Refunds 5 items
-
NameModified ↓
+
NameModified ↓
    -
  • X refunds.xlsxMar 3
  • -
  • X refunds_final.xlsxMar 9
  • -
  • X refunds_final_v2.xlsxMar 9
  • -
  • X refunds_FINAL_real.xlsxApr 2
  • -
  • X refunds (1).xlsxJun 1
  • +
  • X refunds.xlsxMar 3
  • +
  • X refunds_final.xlsxMar 9
  • +
  • X refunds_final_v2.xlsxMar 9
  • +
  • X refunds_FINAL_real.xlsxApr 2
  • +
  • X refunds (1).xlsxJun 1
-

{{icon_img "lock"}} Locked for editing by dave

+

{{icon_img "lock"}} Locked for editing by dave

-

Great until the data drifts.

-

-- which one is real?

-
-
-
- {{icon_img "terminal-2"}} -
-

Custom scripts

-

Python, shell, cron

-
-
+ {{/comparison-card}} + {{#> comparison-card id="compare-scripts" title="Custom scripts" caption="Python, shell, cron" verdict="Works. Until the author leaves." aside="-- cron says it ran" glyph="terminal-2"}}
-
- zsh +
+ zsh ~/reports
@@ -465,41 +442,23 @@

$

-

Works. Until the author leaves.

-

-- cron says it ran

-
-
-
- {{icon_img "chart-bar"}} -
-

BI dashboard

-

Charts on top of your data

-
-
+ {{/comparison-card}} + {{#> comparison-card id="compare-bi" title="BI dashboard" caption="Charts on top of your data" verdict="Great for charts. Not for actions." aside="-- look, but don't touch" glyph="chart-bar"}}
-
Refunds · dashboard
+
Refunds · dashboard
-
+
€12.4krefunded, last 30 days
Edit View-only access
-

SCan we add a button?Not in this tool.

+

SCan we add a button?Not in this tool.

-

Great for charts. Not for actions.

-

-- look, but don't touch

-
-
-
- {{icon_img "apps"}} -
-

Low-code platform

-

Drag-and-drop app builders

-
-
+ {{/comparison-card}} + {{#> comparison-card id="compare-lowcode" title="Low-code platform" caption="Drag-and-drop app builders" verdict="It lives on their terms." aside="-- 14 seats, billed monthly" glyph="apps"}}
-
Refunds app · editor
+
Refunds app · editor
@@ -513,19 +472,17 @@
Export is a Business feature

Upgrade to export refunds as CSV.

-
Not nowSee plans
+
Not nowSee plans
-

It lives on their terms.

-

-- 14 seats, billed monthly

- + {{/comparison-card}} -
OrderCustomerAmount