diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml new file mode 100644 index 0000000..4aab48e --- /dev/null +++ b/.github/workflows/website.yml @@ -0,0 +1,28 @@ +name: Website checks + +on: + push: + paths-ignore: + - README.md + - LICENSE + pull_request: + paths-ignore: + - README.md + - LICENSE + +permissions: + contents: read + +jobs: + website: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: node --check theme.js + - run: npx playwright install --with-deps chromium + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c33015 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/test-results/ +/playwright-report/ diff --git a/README.md b/README.md index 81363d1..cf62399 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ -# sqlstack \ No newline at end of file +# SQLStack + +The SQLStack website: Rust-backed SQL and Python tools for data engineers. + +## Local preview + +Serve this directory with any static web server. For example: + +```sh +python3 -m http.server 4173 +``` + +Open `http://localhost:4173`. No build step or package installation is required to serve the site. + +The page uses self-hosted Inter and Space Grotesk fonts. Their licenses are included in +`assets/fonts/`. Product icons are optimized PNG derivatives of the original brand artwork. + +The theme defaults to the system preference. The accessible header toggle saves an explicit +light/dark preference in the browser. Without JavaScript, the page remains usable in light mode. + +## Checks + +Browser tests cover theme selection and persistence, keyboard navigation, responsive layouts, +product statuses, sharing metadata and image dimensions. They require Node.js and Python 3: + +```sh +npm ci +npx playwright install chromium +npm test +``` + +`CHROMIUM_EXECUTABLE_PATH` can point to an existing Chromium installation instead of downloading one. +The test server starts and stops automatically. GitHub Actions runs the same checks without any +publishing or deployment step. + +## Sharing assets + +`favicon.ico` contains 16px, 32px and 48px icons. PNG favicons and a 180px Apple touch icon use the +original SQLStack mark on white backing for legibility in light and dark browser chrome. +`assets/og-sqlstack.png` is an opaque 1200 × 630 sharing card using the original SQLStack wordmark +and the site's Inter typography. These are raster derivatives, not replacement logos or SVG originals. + +Canonical and Open Graph/Twitter URLs target `https://sqlstack.io/`. Local previews can verify the +metadata and assets, but public social-crawler validation requires a separately authorized deployment. diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png new file mode 100644 index 0000000..ef54de1 Binary files /dev/null and b/assets/apple-touch-icon.png differ diff --git a/assets/favicon-16.png b/assets/favicon-16.png new file mode 100644 index 0000000..c63a40a Binary files /dev/null and b/assets/favicon-16.png differ diff --git a/assets/favicon-32.png b/assets/favicon-32.png new file mode 100644 index 0000000..c8fcab8 Binary files /dev/null and b/assets/favicon-32.png differ diff --git a/assets/fonts/Inter-LICENSE.txt b/assets/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/assets/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Space-Grotesk-LICENSE.txt b/assets/fonts/Space-Grotesk-LICENSE.txt new file mode 100644 index 0000000..baf1dce --- /dev/null +++ b/assets/fonts/Space-Grotesk-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +--- + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1. Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + +2. Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3. No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + +4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5. The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/inter-latin.woff2 b/assets/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/assets/fonts/inter-latin.woff2 differ diff --git a/assets/fonts/space-grotesk-bold.woff2 b/assets/fonts/space-grotesk-bold.woff2 new file mode 100644 index 0000000..de82cb7 Binary files /dev/null and b/assets/fonts/space-grotesk-bold.woff2 differ diff --git a/assets/icons/fensu-dark.png b/assets/icons/fensu-dark.png new file mode 100644 index 0000000..84b309b Binary files /dev/null and b/assets/icons/fensu-dark.png differ diff --git a/assets/icons/fensu.png b/assets/icons/fensu.png new file mode 100644 index 0000000..e537311 Binary files /dev/null and b/assets/icons/fensu.png differ diff --git a/assets/icons/sqlbuild-dark.png b/assets/icons/sqlbuild-dark.png new file mode 100644 index 0000000..71f055c Binary files /dev/null and b/assets/icons/sqlbuild-dark.png differ diff --git a/assets/icons/sqlbuild.png b/assets/icons/sqlbuild.png new file mode 100644 index 0000000..d69b65a Binary files /dev/null and b/assets/icons/sqlbuild.png differ diff --git a/assets/icons/sqlclean-dark.png b/assets/icons/sqlclean-dark.png new file mode 100644 index 0000000..eb5b81e Binary files /dev/null and b/assets/icons/sqlclean-dark.png differ diff --git a/assets/icons/sqlclean.png b/assets/icons/sqlclean.png new file mode 100644 index 0000000..f5aa816 Binary files /dev/null and b/assets/icons/sqlclean.png differ diff --git a/assets/icons/sqload.png b/assets/icons/sqload.png new file mode 100644 index 0000000..0291fa1 Binary files /dev/null and b/assets/icons/sqload.png differ diff --git a/assets/icons/sqlquack.png b/assets/icons/sqlquack.png new file mode 100644 index 0000000..6d461e5 Binary files /dev/null and b/assets/icons/sqlquack.png differ diff --git a/assets/icons/sqlstack-dark.png b/assets/icons/sqlstack-dark.png new file mode 100644 index 0000000..d29890b Binary files /dev/null and b/assets/icons/sqlstack-dark.png differ diff --git a/assets/icons/sqlstack.png b/assets/icons/sqlstack.png new file mode 100644 index 0000000..ac56023 Binary files /dev/null and b/assets/icons/sqlstack.png differ diff --git a/assets/icons/sqltide.png b/assets/icons/sqltide.png new file mode 100644 index 0000000..9bed55a Binary files /dev/null and b/assets/icons/sqltide.png differ diff --git a/assets/og-sqlstack.png b/assets/og-sqlstack.png new file mode 100644 index 0000000..a33e3b9 Binary files /dev/null and b/assets/og-sqlstack.png differ diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..4db1904 Binary files /dev/null and b/favicon.ico differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..8c0ca32 --- /dev/null +++ b/index.html @@ -0,0 +1,166 @@ + + + + + + + + SQLStack | Your data stack. Engineered to change. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Your data stack. Engineered to change.

+

Rust-backed SQL and Python tools for data engineers.

+

Compile-time checks, tested behaviour and enforceable architecture.

+ +
+ +
+
+
+

01 / Transform

+

Build your data.

+

Turn raw data into trusted transformations.

+
+
+ +
+ +
+

SQLTide

+

ClickHouse streaming pipelines with change-aware backfills and bounded replay.

+ +
+
+
+
+ +
+
+

02 / Check

+

Keep it sound.

+

Catch issues early and
keep your code healthy.

+
+
+ +
+ + +
+

Fensu

+

Enforce code boundaries and repository structure.

+ +
+
+
+
+ +
+
+

03 / On the horizon

+

More to come.

+

Exploring what’s next
for data engineers.

+
+
+
+ +
+

SQLQuack

+ Coming soon +
+
+
+ +
+

SQLoad

+ Coming soon +
+
+
+
+
+

* Published benchmark: 10,282 files, default lint rules, 4 CPUs, 0.81 s.

+
+ + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3f7183 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "sqlstack-website", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sqlstack-website", + "devDependencies": { + "@playwright/test": "1.64.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f941af3 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "sqlstack-website", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.64.1" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..b2271a0 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + use: { + baseURL: 'http://127.0.0.1:4180', + launchOptions: process.env.CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.CHROMIUM_EXECUTABLE_PATH } + : {}, + }, + webServer: { + command: 'python3 -m http.server 4180 --bind 127.0.0.1', + url: 'http://127.0.0.1:4180', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..c7ace19 --- /dev/null +++ b/styles.css @@ -0,0 +1,167 @@ +@font-face { + font-family: Inter; + src: url("assets/fonts/inter-latin.woff2") format("woff2"); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} + +@font-face { + font-family: "Space Grotesk"; + src: url("assets/fonts/space-grotesk-bold.woff2") format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +:root { + color-scheme: light; + --ink: #171a1f; + --muted: #566174; + --line: #d8dfe8; + --blue: #1768ba; + --background: #ffffff; + --panel: #ffffff; + --focus: #1768ba; + --button-border: #bdc9da; + --button-hover: #f3f6fa; + --primary: #0866db; + --primary-ink: #ffffff; + --primary-hover: #0755b7; + --gold-ink: #765600; + --gold-bg: #fff3cb; + --pink-ink: #a52138; + --pink-bg: #ffe5e9; + --gutter: clamp(20px, 4.35vw, 68px); + font-family: Inter, sans-serif; + color: var(--ink); + background: var(--background); + font-synthesis: none; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #e9ebef; + --muted: #abb2bf; + --line: #303844; + --blue: #75b5ff; + --background: #0d0f12; + --panel: #171a1f; + --focus: #75b5ff; + --button-border: #626a76; + --button-hover: #171a1f; + --primary: #3788e8; + --primary-ink: #0d0f12; + --primary-hover: #66a6f0; + --gold-ink: #ffe07a; + --gold-bg: #352b12; + --pink-ink: #ffacba; + --pink-bg: #3e2028; +} + +* { box-sizing: border-box; } +body { margin: 0; } +a { color: var(--blue); text-underline-offset: 3px; } +a:hover { text-decoration: underline; } +a:focus-visible, button:focus-visible, [tabindex="-1"]:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 5px; + border-radius: 2px; +} +img { display: block; object-fit: contain; } +.logo-for-dark { display: none; } +[data-theme="dark"] .logo-for-light { display: none; } +[data-theme="dark"] .logo-for-dark { display: block; } +.shell { width: calc(100% - 2 * var(--gutter)); max-width: 1404px; margin-inline: auto; } +.skip-link { position: absolute; top: 8px; left: var(--gutter); padding: 12px 18px; background: var(--background); z-index: 2; transform: translateY(-200%); } +.skip-link:focus { transform: translateY(0); } +.site-header { border-bottom: 1px solid var(--line); } +.header-inner { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 24px; } +.brand { display: inline-flex; align-items: center; gap: 3px; color: var(--ink); text-decoration: none; } +.brand span { font-size: 22px; font-weight: 750; letter-spacing: -.8px; } +.nav-icon { display: inline-grid; place-items: center; width: 44px; height: 44px; color: var(--muted); } +.nav-icon:hover { color: var(--ink); } +.nav-icon:focus-visible { outline-offset: 3px; } +.nav-icon svg { width: 18px; height: 18px; } +.header-actions { display: flex; align-items: center; gap: 8px; } +.theme-toggle { display: inline-grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; color: var(--muted); cursor: pointer; } +.theme-toggle[hidden] { display: none; } +.theme-toggle:hover { color: var(--ink); border-color: var(--muted); } +.theme-toggle:focus-visible { outline-offset: 3px; } +.theme-toggle svg { width: 18px; height: 18px; } +.icon-sun, [data-theme="dark"] .icon-moon { display: none; } +[data-theme="dark"] .icon-sun { display: block; } +.hero { text-align: center; padding: 52px 0 26px; } +h1 { margin: 0; font-size: clamp(40px, 4.2vw, 64px); line-height: 1; letter-spacing: -.055em; font-weight: 800; } +h1 span { display: block; } +.lede { margin: 20px 0 3px; font-size: 22px; line-height: 1.4; } +.principles { margin: 0; color: var(--muted); font-size: 16px; line-height: 1.5; } +.hero-actions { display: flex; justify-content: center; flex-wrap: wrap; gap: 12px; margin-top: 20px; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 14px 20px; min-height: 48px; border: 1px solid var(--button-border); border-radius: 2px; color: var(--ink); font-size: 14px; line-height: 20px; text-decoration: none; } +.button:hover { background: var(--button-hover); text-decoration: none; } +.button.primary { color: var(--primary-ink); background: var(--primary); border-color: var(--primary); } +.button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } +.tool-row { display: grid; grid-template-columns: 304px minmax(0, 1fr); gap: 24px; border-top: 1px solid var(--line); padding: 18px 0 16px; } +.category { padding-top: 8px; } +.category .eyebrow { margin: 0 0 8px; color: var(--muted); font-size: 14px; line-height: 20px; text-transform: uppercase; } +h2 { margin: 0; font-size: 36px; font-weight: 750; line-height: 1.15; letter-spacing: -.04em; } +.category > p:last-child { max-width: 250px; margin: 10px 0 0; font-size: 16px; line-height: 1.4; color: var(--muted); } +.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.product { --accent: #1680ff; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 40px; padding: 20px 28px 14px; border: 1px solid var(--line); border-top: 2px solid var(--accent); background: var(--panel); } +.product-icon { margin-top: 6px; } +.product-content { min-width: 0; display: flex; flex-direction: column; } +h3 { margin: 0; font-family: "Space Grotesk", sans-serif; font-size: 24.94308px; font-weight: 700; line-height: 1.2; letter-spacing: 0; } +.fensu h3 { font-size: 25.5px; } +.product-content > p { margin: 4px 0 12px; min-height: 42px; font-size: 15px; line-height: 1.4; color: var(--muted); } +.text-line { display: block; } +sup { font-size: 11px; line-height: 0; } +sup a { color: var(--muted); text-decoration: none; padding-inline: 2px; } +.product-links { display: flex; gap: 28px; margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); font-size: 14px; line-height: 20px; } +.product-links a { display: inline-flex; align-items: center; gap: 8px; text-decoration: none; } +.product-links a:hover { text-decoration: underline; } +.sqltide { --accent: #00d7dc; } +.sqlclean { --accent: #00c995; } +.fensu { --accent: #ff6b00; } +.sqlquack { --accent: #ffe000; } +.sqload { --accent: #ff5268; } +.upcoming .product { min-height: 128px; align-items: center; padding-block: 22px; } +.upcoming .product-icon { margin-top: 0; } +.upcoming-name { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } +.badge { display: inline-block; padding: 6px 10px; border-radius: 4px; font-size: 12px; line-height: 16px; color: var(--gold-ink); background: var(--gold-bg); white-space: nowrap; } +.sqload .badge { color: var(--pink-ink); background: var(--pink-bg); } +.benchmark { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.5; } +.site-footer { display: flex; justify-content: space-between; gap: 16px; padding: 12px 0 24px; border-top: 1px solid var(--line); font-size: 12px; line-height: 1.5; } +.site-footer span:last-child { color: var(--muted); } +#tools, #benchmark { scroll-margin-top: 20px; } + +@media (max-width: 1200px) { + .tool-row { grid-template-columns: 250px minmax(0, 1fr); } + .product { gap: 24px; padding-inline: 20px; } + h2 { font-size: 32px; } +} + +@media (max-width: 1000px) { + .tool-row { grid-template-columns: minmax(0, 1fr); gap: 24px; padding-block: 28px; } + .category { padding: 0; } + .category > p:last-child { max-width: none; } + .desktop-break { display: none; } + .product { gap: 24px; padding: 22px; } +} + +@media (max-width: 620px) { + .hero { padding: 40px 0 32px; } + h1 { font-size: clamp(34px, 8.5vw, 48px); line-height: 1.07; letter-spacing: -.05em; } + .lede { margin-top: 20px; font-size: 18px; } + .principles { margin-top: 10px; font-size: 15px; } + .hero-actions { gap: 10px; } + .button { padding-inline: 16px; } + .cards { grid-template-columns: minmax(0, 1fr); gap: 16px; } + .category > p:last-child { margin-top: 8px; } + .product { gap: 20px; padding: 22px 18px 18px; } + .product-links a { min-height: 32px; } + .upcoming .product { min-height: 116px; } + .upcoming-name { gap: 8px; } + .badge { padding-inline: 8px; } + .benchmark { margin-bottom: 16px; } + .site-footer { flex-wrap: wrap; } +} diff --git a/tests/website.spec.mjs b/tests/website.spec.mjs new file mode 100644 index 0000000..e048005 --- /dev/null +++ b/tests/website.spec.mjs @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; + +for (const system of ['light', 'dark']) { + test(`theme follows ${system} system preference until an explicit keyboard choice`, async ({ page }) => { + const other = system === 'light' ? 'dark' : 'light'; + await page.emulateMedia({ colorScheme: system }); + await page.goto('/'); + const toggle = page.getByRole('button', { name: 'Dark mode', exact: true }); + await expect(page.locator('html')).toHaveCSS('color-scheme', system); + await expect(toggle).toHaveAttribute('aria-pressed', String(system === 'dark')); + + await page.emulateMedia({ colorScheme: other }); + await expect(page.locator('html')).toHaveCSS('color-scheme', other); + await page.emulateMedia({ colorScheme: system }); + await expect(page.locator('html')).toHaveCSS('color-scheme', system); + + await toggle.focus(); + await page.keyboard.press(system === 'light' ? 'Space' : 'Enter'); + await expect(toggle).toHaveAttribute('aria-pressed', String(other === 'dark')); + await expect(page.locator('html')).toHaveCSS('color-scheme', other); + await page.reload(); + await expect(page.locator('html')).toHaveCSS('color-scheme', other); + await page.emulateMedia({ colorScheme: other }); + await page.emulateMedia({ colorScheme: system }); + await expect(page.locator('html')).toHaveCSS('color-scheme', other); + await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', other === 'dark' ? '#0d0f12' : '#ffffff'); + }); +} + +test('theme control remains usable when browser storage is blocked', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { + get() { throw new DOMException('Storage is blocked', 'SecurityError'); }, + }); + }); + await page.emulateMedia({ colorScheme: 'dark' }); + await page.goto('/'); + await page.getByRole('button', { name: 'Dark mode', exact: true }).click(); + await expect(page.locator('html')).toHaveCSS('color-scheme', 'light'); +}); + +test('approved products, sharing assets and responsive navigation work in both themes', async ({ page, request }) => { + const names = ['SQLBuild', 'SQLTide', 'SQLClean', 'Fensu', 'SQLQuack', 'SQLoad']; + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ colorScheme: theme }); + await page.goto('/'); + await expect(page.getByRole('heading', { level: 3 })).toHaveText(names); + await expect(page.getByRole('link', { name: 'SQLStack on GitHub', exact: true })).toHaveAttribute('href', 'https://github.com/sqlstack-io'); + await expect(page.getByText('Compile-time checks, tested behaviour and enforceable architecture.', { exact: true })).toBeVisible(); + await expect(page.locator('footer')).toContainText('Independent tools. Shared foundations. 100% open source.'); + for (const name of names.slice(4)) { + const card = page.getByRole('article', { name, exact: true }); + await expect(card.getByText('Coming soon', { exact: true })).toBeVisible(); + await expect(card.getByRole('link')).toHaveCount(0); + } + await expect(page.getByRole('article', { name: 'Fensu', exact: true })).not.toContainText(/pre-release|coming soon/i); + for (const width of [320, 390, 768, 1536]) { + await page.setViewportSize({ width, height: 900 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await expect(page.getByRole('button', { name: 'Dark mode', exact: true })).toBeInViewport(); + } + await page.getByRole('link', { name: 'Find a tool', exact: false }).click(); + await expect(page).toHaveURL(/#tools$/); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'SQLBuild website', exact: true })).toBeFocused(); + } + + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://sqlstack.io/'); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute('content', 'https://sqlstack.io/'); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute('content', 'summary_large_image'); + for (const selector of ['meta[property="og:image"]', 'meta[name="twitter:image"]']) { + await expect(page.locator(selector)).toHaveAttribute('content', 'https://sqlstack.io/assets/og-sqlstack.png'); + } + for (const [path, width, height] of [ + ['/assets/og-sqlstack.png', 1200, 630], + ['/assets/favicon-16.png', 16, 16], + ['/assets/favicon-32.png', 32, 32], + ['/assets/apple-touch-icon.png', 180, 180], + ]) { + const response = await request.get(path); + expect(response.ok()).toBe(true); + expect(response.headers()['content-type']).toBe('image/png'); + const png = await response.body(); + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a'); + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([width, height]); + } + const icon = await request.get('/favicon.ico'); + expect(icon.ok()).toBe(true); + const ico = await icon.body(); + expect(ico.readUInt16LE(2)).toBe(1); + expect(ico.readUInt16LE(4)).toBe(3); + expect([ico[6], ico[22], ico[38]]).toEqual([16, 32, 48]); +}); diff --git a/theme.js b/theme.js new file mode 100644 index 0000000..4d4ae7b --- /dev/null +++ b/theme.js @@ -0,0 +1,43 @@ +(() => { + const root = document.documentElement; + const system = window.matchMedia('(prefers-color-scheme: dark)'); + let preference = null; + + try { + const stored = localStorage.getItem('sqlstack-theme'); + if (stored === 'light' || stored === 'dark') preference = stored; + } catch { + // The toggle still works when browser storage is unavailable. + } + + function apply(theme) { + root.dataset.theme = theme; + document.querySelector('meta[name="theme-color"]').content = theme === 'dark' ? '#0d0f12' : '#ffffff'; + const toggle = document.querySelector('[data-theme-toggle]'); + if (toggle) { + toggle.setAttribute('aria-pressed', String(theme === 'dark')); + toggle.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + } + } + + apply(preference || (system.matches ? 'dark' : 'light')); + + system.addEventListener('change', () => { + if (!preference) apply(system.matches ? 'dark' : 'light'); + }); + + document.addEventListener('DOMContentLoaded', () => { + const toggle = document.querySelector('[data-theme-toggle]'); + apply(root.dataset.theme); + toggle.hidden = false; + toggle.addEventListener('click', () => { + preference = root.dataset.theme === 'dark' ? 'light' : 'dark'; + apply(preference); + try { + localStorage.setItem('sqlstack-theme', preference); + } catch { + // Keep the selected theme for this page even without persistent storage. + } + }); + }); +})();