Add screenshots package and fix card-gradient minify bug (#2793)

Co-authored-by: Bartek <xbartoszdobija@gmail.com>
This commit is contained in:
Paweł Kuna
2026-08-07 00:48:28 +02:00
committed by GitHub
co-authored by Bartek
parent cd0b210a87
commit b38cd32d07
82 changed files with 1698 additions and 147 deletions
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
// Serves the built screenshots/dist via `astro preview` and captures a light
// and dark (?theme=dark) PNG — at 1x and @2x — of every page's #screenshot
// canvas (the full 1024x768 frame — logo, gradient backdrop and fake cursor
// included, not just the cropped component card).
// Run via `pnpm run capture` (builds first) — pass slugs as args to capture
// only specific pages, e.g. `pnpm run capture button badge`.
import { chromium, type Page } from 'playwright'
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.join(__dirname, '..')
const distDir = path.join(root, 'dist')
const outDir = path.join(root, 'captures')
const PORT = Number(process.env.CAPTURE_PORT ?? 4020)
const baseUrl = `http://localhost:${PORT}`
function discoverSlugs(): string[] {
if (!existsSync(distDir)) {
console.error(`No build found at ${path.relative(process.cwd(), distDir)} — run \`astro build\` first (or use \`pnpm run capture\`).`)
process.exit(1)
}
const filter = process.argv.slice(2)
return readdirSync(distDir)
.filter((file) => file.endsWith('.html') && file !== 'index.html')
.map((file) => file.replace(/\.html$/, ''))
.filter((slug) => filter.length === 0 || filter.includes(slug))
.sort()
}
// Spawns the `astro` binary directly (not via `pnpm exec astro`) so `child.pid`
// is the actual server process — killing a `pnpm exec` wrapper doesn't reliably
// kill the process it launches, which is how earlier runs left zombie preview
// servers squatting on the port for subsequent runs to collide with.
const astroBin = path.join(root, 'node_modules/.bin/astro')
function startPreviewServer(): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const child = spawn(astroBin, ['preview', '--port', String(PORT)], {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
})
const timer = setTimeout(() => reject(new Error(`astro preview didn't come up on port ${PORT} within 20s`)), 20_000)
const onData = (data: Buffer) => {
if (data.toString().includes('Local')) {
clearTimeout(timer)
child.stdout?.off('data', onData)
resolve(child)
}
}
child.stdout?.on('data', onData)
child.stderr?.on('data', (data) => process.stderr.write(data))
child.once('error', reject)
child.once('exit', (code) => {
if (code !== null && code !== 0) reject(new Error(`astro preview exited with code ${code}`))
})
})
}
function filenameFor(slug: string, theme: 'light' | 'dark', scale: 1 | 2): string {
const themeSuffix = theme === 'dark' ? '-dark' : ''
const scaleSuffix = scale === 2 ? '@2x' : ''
return `${slug}${themeSuffix}${scaleSuffix}.png`
}
async function captureOne(page: Page, slug: string, theme: 'light' | 'dark', scale: 1 | 2) {
await page.goto(`${baseUrl}/${slug}?theme=${theme}`, { waitUntil: 'load' })
await page.waitForFunction(() => document.documentElement.dataset.screenshotReady === 'true', { timeout: 15_000 })
const filename = filenameFor(slug, theme, scale)
await page.locator('#screenshot').screenshot({ path: path.join(outDir, filename) })
console.log(`${filename}`)
}
async function main() {
const slugs = discoverSlugs()
if (slugs.length === 0) {
console.error('No matching pages found to capture.')
process.exit(1)
}
mkdirSync(outDir, { recursive: true })
console.log(`Starting preview server on port ${PORT}`)
const server = await startPreviewServer()
// Interrupting the script (Ctrl+C) must not leave the preview server behind
// squatting on the port for the next run to collide with.
const killServer = () => server.kill()
process.once('SIGINT', killServer)
process.once('SIGTERM', killServer)
try {
const browser = await chromium.launch()
const viewport = { width: 1280, height: 800 }
// Two pages, not one reused with setViewportSize — deviceScaleFactor is
// fixed at context/page creation and can't be changed on an existing page.
const page1x = await browser.newPage({ viewport, deviceScaleFactor: 1 })
const page2x = await browser.newPage({ viewport, deviceScaleFactor: 2 })
for (const slug of slugs) {
console.log(slug)
await captureOne(page1x, slug, 'light', 1)
await captureOne(page1x, slug, 'dark', 1)
await captureOne(page2x, slug, 'light', 2)
await captureOne(page2x, slug, 'dark', 2)
}
await browser.close()
console.log(`\n${slugs.length * 4} screenshots written to ${path.relative(process.cwd(), outDir)}/`)
} finally {
server.kill()
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+9
View File
@@ -0,0 +1,9 @@
# build output
dist/
.astro/
# public/ is fully generated by .build/copy-assets.ts
public/
# generated by `pnpm run capture`
captures/
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+66
View File
@@ -0,0 +1,66 @@
// @ts-check
import { defineConfig } from 'astro/config'
import { fileURLToPath } from 'node:url'
import { copyAssets } from '../.build/copy-assets'
/** @param {string} p */
const path = (p) => fileURLToPath(new URL(p, import.meta.url))
// https://astro.build/config
export default defineConfig({
// pages live at the package root (./pages) — components/lib/data are shared
// (see the @shared alias)
srcDir: '.',
server: {
port: 3020,
// bind on all interfaces so the dev server is reachable from Docker
// port mappings and other devices on the local network
host: true,
},
vite: {
resolve: {
alias: {
'@data': fileURLToPath(new URL('../shared/data', import.meta.url)),
'@shared': fileURLToPath(new URL('../shared', import.meta.url)),
'@ui': fileURLToPath(new URL('../shared/ui', import.meta.url)),
'@components': fileURLToPath(new URL('../shared/components', import.meta.url)),
},
},
},
build: {
// Emit button.html instead of button/index.html — a stable, predictable
// URL per component for the screenshot tool to iterate over.
format: 'file',
},
compressHTML: false,
integrations: [
copyAssets({
repo: path('..'),
publicDir: path('./public'),
copies: [
{
// @tabler/core dist (css/js/fonts/img/libs).
// Fallback keeps current assets if core rebuilds dist mid-copy in turbo dev.
from: path('./node_modules/@tabler/core/dist'),
to: path('./public/dist'),
label: '@tabler/core',
allowDestinationFallback: true,
},
{
// static assets referenced by demo data (avatar photos, brand svgs...).
from: path('../shared/static'),
to: path('./public/static'),
label: 'shared assets',
},
{ from: path('./assets/favicon.ico'), to: path('./public/favicon.ico'), label: '@tabler/screenshots' },
{ from: path('./assets/favicon-dev.ico'), to: path('./public/favicon-dev.ico'), label: '@tabler/screenshots' },
],
// Watch the real core/dist, not the node_modules symlink the startup copy
// reads from — same directory.
syncDirs: [
{ from: path('../core/dist'), to: path('./public/dist') },
{ from: path('../shared/static'), to: path('./public/static') },
],
}),
],
})
+1
View File
@@ -0,0 +1 @@
/// <reference types="astro/client" />
+166
View File
@@ -0,0 +1,166 @@
---
// Chrome-free layout for component screenshots: just @tabler/core CSS/JS
// around a single component, no navbar/sidebar/demo styling. Modeled 1:1 on
// preview/pages/screenshot.astro (the marketing hero-shot page): a fixed
// 1024x768 .screenshot canvas with the Tabler logo above an elevated
// .screenshot-card, on a soft gradient backdrop. Dark/light is driven by the
// same ?theme= query param + dist/js/tabler-theme(.min).js + the
// hide-theme-light/hide-theme-dark nav-segmented toggle as that page, so a
// screenshot tool can request /button.html?theme=dark without depending on
// stored/localStorage state.
import Logo from '@shared/components/navbar/Logo.astro'
import PageScripts from '@shared/components/PageScripts.astro'
import libs from '@tabler/core/libs.json'
interface Props {
title: string
/** dir="rtl" + the .rtl.css variant of tabler.css */
rtl?: boolean
/** the Tabler logo above the card (+ balancing spacer below it) */
showLogo?: boolean
/** caps the [data-screenshot-target] width at columns * 320px */
columns?: number
/** CSS zoom applied to [data-screenshot-target] */
zoom?: number
/** third-party libs from libs.json the component needs, e.g. ['apexcharts'] */
pageLibs?: string[]
/** core CSS plugins (site.cssPlugins), e.g. ['socials'] for tabler-socials.css */
cssPlugins?: string[]
/** extra classes on the component wrapper */
class?: string
}
const { title, rtl = false, showLogo = true, columns, zoom = 1, pageLibs = [], cssPlugins = [], class: className } = Astro.props
const targetStyle = [columns && `max-width: ${columns * 310}px`, `zoom: ${zoom}`].filter(Boolean).join('; ')
const rtlSuffix = rtl ? '.rtl' : ''
// core/dist only has .min. files after a full `pnpm build`; `astro dev` (this
// package's own dev-prepare-less dev script) runs against whatever core's own
// dev watcher last emitted, which is unminified — so match the served core
// build instead of hardcoding one variant.
const min = import.meta.env.DEV ? '' : '.min'
type Lib = { npm?: string; js?: string[]; css?: string[] }
const pageLibEntries = Object.entries(libs as Record<string, Lib>).filter(([name]) => pageLibs.includes(name))
const libHref = (lib: Lib, file: string) => `/dist/libs/${lib.npm}/${file}`
const libCssFiles = pageLibEntries.flatMap(([, lib]) => (lib.css ?? []).map((file) => libHref(lib, file)))
const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file) => libHref(lib, file)))
---
<!doctype html>
<html lang="en" dir={rtl ? 'rtl' : undefined}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{title} - Tabler screenshots</title>
<link rel="icon" href="./favicon-dev.ico" type="image/x-icon" />
<link href={`./dist/css/tabler${rtlSuffix}${min}.css`} rel="stylesheet" />
{cssPlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${rtlSuffix}${min}.css`} rel="stylesheet" />)}
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
<style is:inline>
/* Deterministic screenshots: no mid-capture animation/transition frames, no blinking caret. */
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
caret-color: transparent !important;
}
.screenshot {
width: 1024px;
height: 768px;
background: var(--tblr-bg-surface-tertiary) linear-gradient(to top left, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0));
position: relative;
padding: 0 4rem;
}
.screenshot-wrapper {
width: 100%;
}
.screenshot-wrapper > .card {
box-shadow:
0 1px 1px 0 rgba(0, 0, 0, 0.02),
0 8px 24px -4px rgba(0, 0, 0, 0.04),
0 24px 40px -8px rgba(0, 0, 0, 0.1);
}
.screenshot-logo {
display: flex;
align-items: center;
justify-content: center;
padding: 0 5rem;
flex: 1;
}
/* fake mouse pointer, to make the static screenshot read as a live interaction */
.cursor {
position: absolute;
width: 26px;
height: 37px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 26 37'%3E%3Cg filter='url(%23a)'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M8.56 13.902c-.284-.358-.63-1.092-1.243-1.983-.348-.505-1.211-1.454-1.468-1.935-.223-.426-.2-.617-.146-.97.094-.628.738-1.117 1.425-1.051.519.049.959.392 1.355.716.239.195.533.574.71.788.163.196.203.277.377.509.23.306.302.458.214.12-.071-.495-.187-1.342-.355-2.091-.128-.568-.16-.657-.281-1.093-.13-.464-.195-.79-.316-1.281-.084-.348-.235-1.06-.276-1.46-.057-.546-.087-1.438.264-1.848.275-.321.906-.418 1.296-.22.513.259.803 1.003.937 1.3.238.534.386 1.15.515 1.96.165 1.032.466 2.463.476 2.764.024-.37-.068-1.146-.004-1.5.059-.321.329-.694.667-.795.285-.085.62-.116.915-.055.313.064.643.288.767.499.362.624.369 1.899.384 1.83.085-.375.07-1.228.284-1.583.14-.234.496-.445.686-.48.295-.051.655-.067.964-.007.25.049.587.345.677.487.218.344.343 1.317.38 1.658.015.14.073-.392.293-.736.405-.64 1.843-.763 1.898.639.025.654.02.624.02 1.064 0 .517-.013.828-.04 1.202-.031.4-.117 1.303-.242 1.742-.087.3-.372.977-.652 1.383 0 0-1.075 1.25-1.192 1.814-.117.562-.079.566-.102.964-.023.398.122.922.122.922s-.802.104-1.235.035c-.39-.063-.875-.84-1-1.079-.171-.328-.539-.264-.681-.023-.226.383-.71 1.07-1.051 1.113-.668.084-2.054.032-3.14.02 0 0 .185-1.01-.226-1.357-.306-.26-.83-.784-1.144-1.06l-.832-.921Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' d='M16.794 14.257v-3.459m-2.015 3.47-.016-3.472m-1.98.031.02 3.426m-4.243-.35c-.284-.36-.63-1.094-1.243-1.985-.348-.503-1.211-1.452-1.468-1.934-.223-.426-.2-.617-.146-.97.094-.628.738-1.117 1.425-1.051.519.049.959.392 1.355.716.239.195.533.574.71.788.163.196.203.277.377.509.23.306.302.458.214.12-.071-.495-.187-1.342-.355-2.091-.128-.568-.16-.657-.281-1.093-.13-.464-.195-.79-.316-1.281-.084-.348-.235-1.06-.276-1.46-.057-.546-.087-1.438.264-1.848.275-.321.906-.418 1.296-.22.513.259.803 1.003.937 1.3.238.534.386 1.15.515 1.96.165 1.032.466 2.463.476 2.764.024-.37-.068-1.146-.004-1.5.059-.321.329-.694.667-.795.285-.085.62-.116.915-.055.313.064.643.288.767.499.362.624.369 1.899.384 1.83.085-.375.07-1.228.284-1.583.14-.234.496-.445.686-.48.295-.051.655-.067.964-.007.25.049.587.345.677.487.218.344.343 1.317.38 1.658.015.14.073-.392.293-.736.405-.64 1.843-.763 1.898.639.025.654.02.624.02 1.064 0 .517-.013.828-.04 1.202-.031.4-.117 1.303-.242 1.742-.087.3-.372.977-.652 1.383 0 0-1.075 1.25-1.192 1.814-.117.562-.079.566-.102.964-.023.398.122.922.122.922s-.802.104-1.235.035c-.39-.063-.875-.84-1-1.079-.171-.328-.539-.264-.681-.023-.226.383-.71 1.07-1.051 1.113-.668.084-2.054.032-3.14.02 0 0 .185-1.01-.226-1.357-.306-.26-.83-.784-1.144-1.06l-.832-.921Z'/%3E%3C/g%3E%3Cdefs%3E%3Cfilter id='a' width='30' height='40' x='-2' y='-1' color-interpolation-filters='sRGB' filterUnits='userSpaceOnUse'%3E%3CfeFlood flood-opacity='0' result='BackgroundImageFix'/%3E%3CfeColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'/%3E%3CfeOffset dy='1'/%3E%3CfeGaussianBlur stdDeviation='1'/%3E%3CfeColorMatrix values='0 0 0 0 0.156863 0 0 0 0 0.156863 0 0 0 0 0.2 0 0 0 0.05 0'/%3E%3CfeBlend in2='BackgroundImageFix' result='effect1_dropShadow_55_1571'/%3E%3CfeColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'/%3E%3CfeOffset dy='3'/%3E%3CfeGaussianBlur stdDeviation='1.5'/%3E%3CfeColorMatrix values='0 0 0 0 0.156863 0 0 0 0 0.156863 0 0 0 0 0.2 0 0 0 0.04 0'/%3E%3CfeBlend in2='effect1_dropShadow_55_1571' result='effect2_dropShadow_55_1571'/%3E%3CfeColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'/%3E%3CfeOffset dy='8'/%3E%3CfeGaussianBlur stdDeviation='2.5'/%3E%3CfeColorMatrix values='0 0 0 0 0.156863 0 0 0 0 0.156863 0 0 0 0 0.2 0 0 0 0.03 0'/%3E%3CfeBlend in2='effect2_dropShadow_55_1571' result='effect3_dropShadow_55_1571'/%3E%3CfeColorMatrix in='SourceAlpha' result='hardAlpha' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'/%3E%3CfeOffset dy='14'/%3E%3CfeGaussianBlur stdDeviation='2.5'/%3E%3CfeColorMatrix values='0 0 0 0 0.156863 0 0 0 0 0.156863 0 0 0 0 0.2 0 0 0 0.01 0'/%3E%3CfeBlend in2='effect3_dropShadow_55_1571' result='effect4_dropShadow_55_1571'/%3E%3CfeBlend in='SourceGraphic' in2='effect4_dropShadow_55_1571' result='shape'/%3E%3C/filter%3E%3C/defs%3E%3C/svg%3E");
top: calc(100% - 10px);
left: calc(50% - 10px);
}
</style>
<!-- loaded early, before paint, so ?theme=dark applies before first render -->
<script is:inline src={`/dist/js/tabler-theme${min}.js`}></script>
</head>
<body>
<div class="page page-center align-items-center">
<div class="screenshot d-flex flex-column justify-content-center align-items-center px-12" id="screenshot">
{showLogo && <div class="screenshot-logo" />}
<div class:list={['screenshot-wrapper', className]} style={targetStyle} data-screenshot-target>
<slot />
</div>
{
showLogo && (
<div class="screenshot-logo">
<Logo gray />
</div>
)
}
</div>
<!-- outside .screenshot on purpose: a screenshot tool crops to [data-screenshot-target], so this toggle never ends up in the capture -->
<nav class="nav-segmented mt-4">
<a href="?theme=light" class="nav-link hide-theme-light">Light</a>
<a href="?theme=light" class="nav-link active hide-theme-dark">Light</a>
<a href="?theme=dark" class="nav-link hide-theme-dark">Dark</a>
<a href="?theme=dark" class="nav-link active hide-theme-light">Dark</a>
</nav>
</div>
{libJsFiles.map((src) => <script is:inline src={src} defer />)}
<script is:inline src={`/dist/js/tabler${min}.js`} defer></script>
<!-- components that render via CaptureScript (e.g. Chart) queue their init script
into shared/lib/page-scripts instead of inlining it where used; this drains it. -->
<PageScripts />
<script>
// Signals to the screenshot tool that fonts and JS-driven component init
// (dropdowns, tooltips, charts via PageScripts, ...) are settled.
// DOMContentLoaded — not window "load" — on purpose: deferred scripts
// (tabler(.min).js above, and everything PageScripts drained) already run
// before DOMContentLoaded per spec, and unlike "load" it doesn't block on
// slow/unreachable subresources (e.g. an external chat GIF).
const domReady = new Promise((resolve) => {
if (document.readyState !== 'loading') resolve(undefined)
else document.addEventListener('DOMContentLoaded', () => resolve(undefined), { once: true })
})
Promise.all([document.fonts.ready, domReady]).then(() => {
requestAnimationFrame(() => requestAnimationFrame(() => (document.documentElement.dataset.screenshotReady = 'true')))
})
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@tabler/screenshots",
"private": true,
"type": "module",
"version": "0.0.1",
"description": "Astro app: one page per UI component, for automated screenshotting",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"capture": "turbo build --filter=@tabler/screenshots && tsx .build/capture.ts",
"clean": "shx rm -rf dist public captures/* .astro",
"type-check": "astro check"
},
"dependencies": {
"@tabler/core": "workspace:*",
"astro": "^7.1.6"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@types/node": "^26.1.1",
"playwright": "1.62.0",
"shx": "^0.4.0",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ActiveUsers from '@shared/components/cards/charts/ActiveUsers.astro'
---
<ScreenshotLayout title="Active subscriptions" columns={1} pageLibs={['apexcharts']}>
<ActiveUsers />
</ScreenshotLayout>
+13
View File
@@ -0,0 +1,13 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Alert from '@ui/Alert.astro'
---
<ScreenshotLayout title="Alert" columns={2}>
<div class="space-y">
<Alert type="success" title="Well done!" description="You successfully read this important alert message." />
<Alert type="warning" title="Warning!" description="Better check yourself, you're not looking too good." />
<Alert type="danger" title="Oh snap!" description="Change a few things up and try submitting again." showClose />
<Alert type="info" title="Heads up!" description="This alert needs your attention, but it's not super important." />
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import AuthLockCard from '@shared/components/cards/AuthLockCard.astro'
---
<ScreenshotLayout title="Account Locked" columns={1}>
<AuthLockCard />
</ScreenshotLayout>
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import AuthenticateAccount from '@shared/components/cards/AuthenticateAccount.astro'
---
<ScreenshotLayout title="Authenticate Your Account" columns={1.5}>
<AuthenticateAccount />
</ScreenshotLayout>
@@ -0,0 +1,30 @@
---
// .bg-pattern-{color} sets --tblr-pattern-color; combine with a pattern-type
// class (core/scss/ui/_patterns.scss).
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import BackgroundPattern from '@ui/BackgroundPattern.astro'
import site from '@data/site.json'
const patternColors = Object.keys(site.colors)
---
<ScreenshotLayout title="Background pattern colors" columns={2}>
<Card>
<CardBody>
<CardTitle>Pattern colors</CardTitle>
<div class="row row-cols-4 g-3">
{
patternColors.map((color) => (
<div class="col">
<BackgroundPattern pattern="rectangles" color={color} class="rounded mb-2" style="height: 5rem;" />
<code>.bg-pattern-{color}</code>
</div>
))
}
</div>
</CardBody>
</Card>
</ScreenshotLayout>
@@ -0,0 +1,30 @@
---
// .bg-pattern-{type} (core/scss/ui/_patterns.scss) — pure CSS background-image
// utilities, no color/size modifier needed for the default look (they read
// --tblr-body-color at low opacity out of the box).
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import BackgroundPattern from '@ui/BackgroundPattern.astro'
const patterns = ['diagonal', 'dots', 'rectangles', 'grid', 'blueprint', 'circles', 'zigzag', 'diagonal-stripes'] as const
---
<ScreenshotLayout title="Background pattern utilities" columns={2}>
<Card>
<CardBody>
<CardTitle>Pattern types</CardTitle>
<div class="row row-cols-4 g-3">
{
patterns.map((pattern) => (
<div class="col">
<BackgroundPattern pattern={pattern} class="rounded mb-2" style="height: 5rem;" />
<code>.bg-pattern-{pattern}</code>
</div>
))
}
</div>
</CardBody>
</Card>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import CryptoPrice from '@shared/components/cards/charts/CryptoPrice.astro'
---
<ScreenshotLayout title="Candlestick chart" columns={2} pageLibs={['apexcharts']}>
<CryptoPrice />
</ScreenshotLayout>
+52
View File
@@ -0,0 +1,52 @@
---
// .card-gradient + .card-gradient-{variant} come from core/scss/ui/_cards.scss
// (bundled in the base tabler.css — no cssPlugins needed here). Content follows
// shared/components/cards/StatGradient.astro's pattern (subheader + big stat +
// icon avatar + progress bar) rather than reusing it directly — its `color`
// prop assumes a single real theme color drives BOTH the gradient background
// and the icon/progress accent, which doesn't hold for the named palettes
// below (rainbow, ocean, ...); accentColor is picked separately so the icon
// stays legible in dark mode too ("dark" text is invisible on a dark theme).
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Subheader from '@ui/Subheader.astro'
import Avatar from '@ui/Avatar.astro'
import Progress from '@ui/Progress.astro'
const variants = [
{ name: 'rainbow', accentColor: 'pink', label: 'Palettes', icon: 'palette', value: '24', progress: 80 },
{ name: 'ocean', accentColor: 'azure', label: 'Revenue', icon: 'droplet', value: '$12.4k', progress: 62 },
{ name: 'sun', accentColor: 'orange', label: 'Temperature', icon: 'sun', value: '32°C', progress: 74 },
{ name: 'snow', accentColor: 'cyan', label: 'Temperature', icon: 'snowflake', value: '-4°C', progress: 18 },
{ name: 'gold', accentColor: 'yellow', label: 'Rank', icon: 'trophy', value: '#1', progress: 96 },
{ name: 'disco', accentColor: 'purple', label: 'Tempo', icon: 'music', value: '128 BPM', progress: 55 },
]
---
<ScreenshotLayout title="Card gradient" columns={2}>
<div class="row row-cards">
{
variants.map((variant) => (
<div class="col-6">
<div class={`card card-gradient card-gradient-${variant.name}`}>
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col">
<Subheader as="h4" class="mb-1">
{variant.label}
</Subheader>
<div class="h3 m-0">{variant.value}</div>
</div>
<div class="col-auto">
<Avatar icon={variant.icon} color={variant.accentColor} />
</div>
<div class="col-12">
<Progress value={variant.progress} color={variant.accentColor} size="sm" />
</div>
</div>
</div>
</div>
</div>
))
}
</div>
</ScreenshotLayout>
+12
View File
@@ -0,0 +1,12 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ChartRadial from '@ui/ChartRadial.astro'
---
<ScreenshotLayout title="Task progress" columns={1} pageLibs={['apexcharts']}>
<div class="card">
<div class="card-body">
<ChartRadial title="Task progress" value={72} />
</div>
</div>
</ScreenshotLayout>
+12
View File
@@ -0,0 +1,12 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Chat from '@ui/Chat.astro'
---
<ScreenshotLayout title="Chat" columns={2}>
<div class="card">
<div class="card-body">
<Chat />
</div>
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ConversionRate from '@shared/components/cards/charts/ConversionRate.astro'
---
<ScreenshotLayout title="Conversion rate" columns={1}>
<ConversionRate />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import TrafficSources from '@shared/components/cards/charts/TrafficSources.astro'
---
<ScreenshotLayout title="Donut chart" columns={1} pageLibs={['apexcharts']}>
<TrafficSources />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Empty from '@ui/Empty.astro'
---
<ScreenshotLayout title="Empty state" columns={2}>
<Empty illustration="not-found.svg" />
</ScreenshotLayout>
+20
View File
@@ -0,0 +1,20 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import allFlags from '@data/flags.json'
// 12 most popular countries, by population/economy/recognizability.
const popularCodes = ['us', 'gb', 'de', 'fr', 'cn', 'jp', 'in', 'br', 'ca', 'au', 'pl', 'es']
const flags = popularCodes.map((code) => allFlags.find((country) => country.flag === code)).filter((country) => country !== undefined)
---
<ScreenshotLayout title="Flags list" columns={2} cssPlugins={['flags']} zoom={1.5}>
<div class="d-grid gap-4" style="grid-template-columns: repeat(4, auto); justify-content: center; align-items: center;">
{
flags.map((country) => (
<span title={country.name}>
<span class={`flag flag-country-${country.flag}`} />
</span>
))
}
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import HappyBirthday from '@shared/components/cards/HappyBirthday.astro'
---
<ScreenshotLayout title="Happy Birthday" columns={1}>
<HappyBirthday />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import WeeklyActivity from '@shared/components/cards/charts/WeeklyActivity.astro'
---
<ScreenshotLayout title="Heatmap chart" columns={2} pageLibs={['apexcharts']}>
<WeeklyActivity />
</ScreenshotLayout>
+26
View File
@@ -0,0 +1,26 @@
---
// Every other page under pages/ is a screenshot target. Listing them via a
// glob (instead of a hand-maintained array) means adding a new component
// page is the only step needed for it to show up here.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
const pageModules = import.meta.glob('./*.astro')
const pages = Object.keys(pageModules)
.map((path) => path.replace(/^\.\//, '').replace(/\.astro$/, ''))
.filter((slug) => slug !== 'index')
.sort()
---
<ScreenshotLayout title="Components" columns={2}>
<div class="card">
<div class="card-body">
{
pages.map((slug) => (
<a href={`/${slug}`} class="d-block">
{slug.replace(/-/g, ' ')}
</a>
))
}
</div>
</div>
</ScreenshotLayout>
+38
View File
@@ -0,0 +1,38 @@
---
// MapVectorCard.astro has no room for a header legend/stats row, so the card
// is hand-built here (same shape as MapVectorCard.astro) instead of reusing it.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import MapVector from '@ui/MapVector.astro'
import Subheader from '@ui/Subheader.astro'
import ScaleLegend from '@ui/ScaleLegend.astro'
---
<ScreenshotLayout title="Map" columns={2} pageLibs={['jsvectormap']} cssPlugins={['flags']}>
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<h3 class="card-title m-0">Locations</h3>
<ScaleLegend class="ms-auto" />
</div>
<MapVector mapId="world" color="primary" ratio="21x9" />
<div class="row g-3 mt-1">
<div class="col-4">
<Subheader>Countries</Subheader>
<div class="h3 m-0">182</div>
</div>
<div class="col-4">
<Subheader>Top country</Subheader>
<div class="h3 m-0 d-flex align-items-center gap-2">
<span class="flag flag-xs flag-country-pl"></span>
Poland
</div>
</div>
<div class="col-4">
<Subheader>Total visits</Subheader>
<div class="h3 m-0">84,392</div>
</div>
</div>
</div>
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import NewClients from '@shared/components/cards/charts/NewClients.astro'
---
<ScreenshotLayout title="New clients" columns={1} pageLibs={['apexcharts']}>
<NewClients />
</ScreenshotLayout>
+21
View File
@@ -0,0 +1,21 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Payment from '@ui/Payment.astro'
import allPayments from '@data/payments.json'
// 12 most globally recognized payment providers.
const popularLogos = ['visa', 'mastercard', 'paypal', 'applepay', 'google-pay', 'stripe', 'americanexpress', 'klarna', 'amazon-pay', 'samsung-pay', 'unionpay', 'alipay']
const payments = popularLogos.map((logo) => allPayments.find((item) => item.logo === logo)).filter((item) => item !== undefined)
---
<ScreenshotLayout title="Payments list" columns={2} cssPlugins={['payments']} zoom={1.5}>
<div class="d-grid gap-4" style="grid-template-columns: repeat(4, auto); justify-content: center; align-items: center;">
{
payments.map((item) => (
<span title={item.name}>
<Payment payment={item.logo} dark={true} />
</span>
))
}
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Revenue from '@shared/components/cards/charts/Revenue.astro'
---
<ScreenshotLayout title="Revenue" columns={1} pageLibs={['apexcharts']}>
<Revenue />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import SalesOverview from '@shared/components/cards/charts/SalesOverview.astro'
---
<ScreenshotLayout title="Sales overview" pageLibs={['apexcharts']} columns={2}>
<SalesOverview />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Sales from '@shared/components/cards/charts/Sales.astro'
---
<ScreenshotLayout title="Sales" columns={1}>
<Sales />
</ScreenshotLayout>
+23
View File
@@ -0,0 +1,23 @@
---
// .social-app-{file} icon classes come from the "socials" core CSS plugin
// (tabler-socials.css) — not bundled in the base tabler.css, hence cssPlugins.
// The grid itself is built from scratch here (not preview's .demo-icons-list)
// since that class lives in preview/scss/demo.scss, which this app never loads.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro'
import socials from '@data/socials.json'
---
<ScreenshotLayout title="List of all social media icons" columns={2} cssPlugins={['socials']} zoom={1.25}>
<div class="d-flex flex-wrap gap-4 justify-content-center align-items-center">
{
socials.map((item) => (
<span title={item.name}>
<span class={`social social-app-${item.file}`} />
</span>
))
}
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import SocialReferrals from '@shared/components/cards/charts/SocialReferrals.astro'
---
<ScreenshotLayout title="Social referrals" columns={2} pageLibs={['apexcharts']}>
<SocialReferrals />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import TeamLeaderboard from '@shared/components/cards/TeamLeaderboard.astro'
---
<ScreenshotLayout title="Team leaderboard" columns={2}>
<TeamLeaderboard />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import TotalUsers from '@shared/components/cards/charts/TotalUsers.astro'
---
<ScreenshotLayout title="Total Users" columns={1} pageLibs={['apexcharts']}>
<TotalUsers />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import StatusMonitoring from '@shared/components/cards/StatusMonitoring.astro'
---
<ScreenshotLayout title="Tracking" columns={1}>
<StatusMonitoring />
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import UserCardBg from '@shared/components/cards/UserCardBg.astro'
---
<ScreenshotLayout title="User card" columns={1}>
<UserCardBg />
</ScreenshotLayout>
+12
View File
@@ -0,0 +1,12 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import InlinePlayer from '@ui/InlinePlayer.astro'
---
<ScreenshotLayout title="Vimeo Player" columns={2} pageLibs={['plyr']}>
<div class="card">
<div class="card-body">
<InlinePlayer id="charlotte" type="vimeo" embedId={707012696} />
</div>
</div>
</ScreenshotLayout>
+8
View File
@@ -0,0 +1,8 @@
---
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Welcome from '@shared/components/cards/Welcome.astro'
---
<ScreenshotLayout title="Welcome" columns={2}>
<Welcome />
</ScreenshotLayout>
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "public"],
"compilerOptions": {
"paths": {
"@data/*": ["../shared/data/*"],
"@shared/*": ["../shared/*"],
"@ui/*": ["../shared/ui/*"],
"@components/*": ["../shared/components/*"]
},
"baseUrl": "."
}
}