Add 1.5 feature demos to the screenshots package and fix the capture script (#2954)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-05 14:54:28 +02:00
1 parent b3e873a0f7
commit 8abfd0989c
26 files changed
+1018 -71

No files matched your search

+77 -40
View File
@@ -3,10 +3,17 @@
// 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).
// Only the @2x picture is rasterized by the browser; the 1x file is that
// picture downscaled with a Lanczos filter. Chromium renders a 1x surface with
// plain grayscale antialiasing, and hairlines (chart grids, borders) snap to
// whole pixels there; a supersampled 1x keeps their weight and gives text a
// smoother edge. Playwright's own `scale: 'css'` does not do this — it simply
// rasterizes at 1x.
// 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 sharp from 'sharp'
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -33,50 +40,85 @@ function discoverSlugs(): string[] {
.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.
// Spawns the `astro` binary directly (not via `pnpm exec astro`) so the CLI's
// own background handling is the only thing between us and the server.
// Astro detaches `preview` into a background daemon whenever stdout is not a
// TTY (which it never is here) and prints a JSON status line instead of the
// human "Local http://…" banner, so readiness is polled over HTTP rather than
// scraped from stdout, and the server is stopped through `astro preview stop`
// rather than by killing the process we spawned — that one has already exited.
const astroBin = path.join(root, 'node_modules/.bin/astro')
function startPreviewServer(): Promise<ChildProcess> {
function runAstro(args: string[]): Promise<void> {
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)
const child = spawn(astroBin, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })
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}`))
if (code === null || code === 0) resolve()
else reject(new Error(`astro ${args.join(' ')} exited with code ${code}`))
})
})
}
async function startPreviewServer(): Promise<void> {
await runAstro(['preview', '--background', '--port', String(PORT)])
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
try {
const response = await fetch(baseUrl, { method: 'HEAD' })
if (response.ok || response.status === 404) return
} catch {
// not listening yet
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(`astro preview didn't come up on port ${PORT} within 20s`)
}
const stopPreviewServer = () => runAstro(['preview', 'stop']).catch(() => {})
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) {
async function captureOne(page: Page, slug: string, theme: 'light' | 'dark') {
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}`)
// Page furniture that exists for the human browsing the pages (the light/dark
// toggle). The app frame fills the viewport, so anything left on screen would
// sit inside the captured rectangle.
await page.addStyleTag({ content: '[data-screenshot-chrome] { display: none !important; }' })
const frame = page.locator('#screenshot')
// The picture is always the frame's CSS size, times the device scale for @2x.
// A layout may render its frame larger than the picture it wants so the page
// shows more (data-screenshot-width, see ScreenshotAppLayout); the raster is
// then reduced to that width, keeping the frame's aspect ratio.
const [cssWidth, cssHeight, targetWidth] = await frame.evaluate((el) => {
const { width, height } = el.getBoundingClientRect()
const wanted = Number((el as HTMLElement).dataset.screenshotWidth ?? width)
return [width, height, wanted]
})
const width1x = Math.round(targetWidth)
const height1x = Math.round((targetWidth * cssHeight) / cssWidth)
const raster = await frame.screenshot()
const write = async (filename: string, factor: 1 | 2) => {
await sharp(raster)
.resize(width1x * factor, height1x * factor, { kernel: 'lanczos3' })
.png()
.toFile(path.join(outDir, filename))
console.log(` ✓ ${filename}`)
}
await write(filenameFor(slug, theme, 2), 2)
await write(filenameFor(slug, theme, 1), 1)
}
async function main() {
@@ -89,33 +131,28 @@ async function main() {
mkdirSync(outDir, { recursive: true })
console.log(`Starting preview server on port ${PORT}…`)
const server = await startPreviewServer()
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)
process.once('SIGINT', stopPreviewServer)
process.once('SIGTERM', stopPreviewServer)
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 })
// Wide enough for the app frame (1708x1068, see ScreenshotAppLayout); the
// 1024x768 card frame sits centered in it on whole pixels.
const page = await browser.newPage({ viewport: { width: 1708, height: 1068 }, 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 captureOne(page, slug, 'light')
await captureOne(page, slug, 'dark')
}
await browser.close()
console.log(`\n${slugs.length * 4} screenshots written to ${path.relative(process.cwd(), outDir)}/`)
} finally {
server.kill()
await stopPreviewServer()
}
}
+39
View File
@@ -0,0 +1,39 @@
# Tabler screenshots
Pictures of Tabler components and screens, made the same way every time.
We use them wherever Tabler needs to show itself: release notes, the website, the README, social posts. Each picture comes from a small page in this package, so it can be made again after any change to Tabler — with the same framing, the same fonts and the same data.
## What is in here
- `pages/` — one page per picture. A page is a component or a whole app screen, set up to look its best.
- `layouts/` — the two frames a page can use: a single component on a card, or a full app screen with sidebar and navbar.
- `captures/` — the finished PNG files. This folder is not committed; run the capture to fill it.
Every page is captured in light and dark mode, in normal and 2× size — four files per page.
## How to use it
Look at the pages in the browser:
```sh
pnpm --dir screenshots run dev
```
Make the pictures:
```sh
pnpm --dir screenshots run capture
```
To make pictures of a few pages only, name them:
```sh
pnpm --dir screenshots run capture chart-radar dashboard-crm
```
## Adding a picture
Copy one of the pages in `pages/` and change what it shows. The file name becomes the name of the pictures. Open the page in the browser in both color modes, then run the capture for that page and check the four files.
The pages use the same components as the Tabler demo, so anything the demo can show, a picture can show too.
+68
View File
@@ -0,0 +1,68 @@
---
// The CRM dashboard body, shared by dashboard-crm.astro and the theme variants
// (dashboard-theme-*.astro) so every one of them shows exactly the same screen
// and only the theme differs.
import CrmStats from '@shared/components/cards/CrmStats.astro'
import PipelineFunnel from '@shared/components/cards/charts/PipelineFunnel.astro'
import TopDeals from '@shared/components/cards/TopDeals.astro'
import Chart from '@components/demo/Chart.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import CardActions from '@ui/CardActions.astro'
import Subheader from '@ui/Subheader.astro'
// `revenue-vs-orders-plain` extends the docs chart without its rotated axis
// titles: the header and the legend already name both series here.
// Year totals of the two series in charts.json, so the header agrees with the plot.
const figures = [
{ label: 'Revenue', value: '$229.4K' },
{ label: 'Orders', value: '3,234' },
]
---
<div class="row row-cards">
<div class="col-6 col-lg-3">
<CrmStats color="primary" icon="currency-dollar" lt title="MRR" description="$92.4K" changeValue="+9.5" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="azure" icon="filter" lt title="Pipeline" description="$2.4M" changeValue="+14" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="green" icon="circle-check" lt title="Deals won" description="74" changeValue="+8" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="purple" icon="percentage" lt title="Win rate" description="38%" changeValue="+0.4" />
</div>
<div class="col-lg-7">
<Card>
<CardHeader>
<div>
<CardTitle>Revenue vs orders</CardTitle>
<CardSubtitle>Monthly revenue against order volume, last 12 months</CardSubtitle>
</div>
<CardActions class="d-flex gap-4 text-end">
{
figures.map((figure) => (
<div>
<Subheader>{figure.label}</Subheader>
<div class="fw-semibold">{figure.value}</div>
</div>
))
}
</CardActions>
</CardHeader>
<CardBody>
<Chart chartId="revenue-vs-orders-plain" height={14} label="Monthly revenue in dollars against the order count" />
</CardBody>
</Card>
</div>
<div class="col-lg-5">
<PipelineFunnel />
</div>
<div class="col-12">
<TopDeals />
</div>
</div>
@@ -0,0 +1,196 @@
---
// Chrome-full counterpart to ScreenshotLayout: a whole app screen (sidebar,
// navbar, page header and body) inside one capture frame, for the features
// that only exist at layout level — the folded sidebar, the theme settings
// panel, the navbar dropdowns.
//
// The frame is 1708x1068 CSS px — the capture viewport itself, not the
// 1024x768 card frame of ScreenshotLayout — and the capture scales it down to
// the 1280 px wide picture it asks for (data-screenshot-width), i.e. by 0.75.
// That is how the screen fits more of the page than a 1280 px window would:
// the browser lays out and rasterizes a real 1708 px screen, so charts measure
// their axes right and the grid uses the full width, and only the finished
// raster is reduced. (Even numbers on purpose: the card frame centers in the
// same viewport and must land on whole pixels.) A CSS
// `zoom` on the page would show the same amount but clips ApexCharts axis
// labels and leaves the container short of the frame's edge.
// The frame matches the viewport because media queries resolve against the
// viewport: a narrower frame would lay the grid out for a width the picture
// does not show.
//
// It also carries `transform: translateZ(0)` on purpose: `.navbar-vertical`
// and the offcanvas panels are `position: fixed`, and a transformed ancestor
// becomes their containing block, so they anchor to the frame.
import Navbar from '@shared/components/navbar/Navbar.astro'
import Sidebar from '@shared/components/navbar/Sidebar.astro'
import PageHeader from '@shared/components/layout/PageHeader.astro'
import PageScripts from '@shared/components/PageScripts.astro'
import ThemeSettings from '@shared/components/demo/ThemeSettings.astro'
import { themeDataAttributes, type ThemeKey } from '@shared/lib/theme-config'
import libs from '@tabler/core/libs.json'
interface Props {
title: string
/** page header title; defaults to `title` */
pageHeader?: string
/** text below the page title */
description?: string
/** active menu entry, e.g. "dashboards.crm" */
pageMenu?: string
/** icon-only sidebar rail: folded, or folded until hovered */
sidebarMode?: 'folded' | 'folded-hover'
/** where the main menu lives */
navbarPosition?: 'horizontal' | 'vertical'
/** container width; fluid by default so the page fills the wide frame instead of centering a capped column */
layout?: 'default' | 'fluid' | 'boxed'
/** renders the theme settings offcanvas (needed for a capture of the panel) */
themeSettings?: boolean
/**
* Theme switcher values rendered as data-bs-* on <html> — `theme-primary`,
* `theme-font`, `theme-base`, `theme-radius` (see themeDefaults). Pulls in
* tabler-themes.css, which holds the variants.
*/
theme?: Partial<Record<ThemeKey, string>>
/** third-party libs from libs.json the page needs, e.g. ['apexcharts'] */
pageLibs?: string[]
/** core CSS plugins (site.cssPlugins), e.g. ['payments'] */
cssPlugins?: string[]
}
const { title, pageHeader, description, pageMenu, sidebarMode, navbarPosition = 'vertical', layout = 'fluid', themeSettings = false, theme = {}, pageLibs = [], cssPlugins = [] } = Astro.props
// tabler-vendors.css carries the third-party styling (the ApexCharts tokens
// among them, which is what themes a chart's labels, grid and legend for the
// color mode), so a page that pulls a lib in gets it without asking.
const stylePlugins = [...new Set([...(pageLibs.length > 0 ? ['vendors'] : []), ...(Object.keys(theme).length > 0 ? ['themes'] : []), ...cssPlugins])]
const headerTitle = pageHeader ?? title
// Same as BaseLayout: the page states its own layout as data-bs-* on <html>.
const themeAttributes = themeDataAttributes({ ...theme, 'navbar-position': navbarPosition, 'layout': layout, 'sidebar': sidebarMode } as Partial<Record<ThemeKey, string | undefined>>)
// core/dist only has .min. files after a full `pnpm build`; `astro dev` serves
// whatever core's own watcher last emitted, which is unminified.
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" {...themeAttributes}>
<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${min}.css`} rel="stylesheet" />
{stylePlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${min}.css`} rel="stylesheet" />)}
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
<!-- Inter for the captures: Tabler ships system font stacks (no web font),
and the body's cv03/cv04/cv11 feature settings were tuned for Inter, so the
pictures show the face the design was drawn in — the same on every machine
that runs the capture. The serif and monospace theme variants keep their own
stacks; only the sans token is replaced. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style is:inline>
:root {
--tblr-font-sans-serif: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
/* Deterministic screenshots: no mid-capture animation/transition frames, no blinking caret. */
*,
*::before,
*::after {
transition: none !important;
animation: none !important;
caret-color: transparent !important;
}
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--tblr-bg-surface-tertiary);
}
.screenshot {
width: 1708px;
height: 1068px;
overflow: hidden;
/* containing block for the layout's position: fixed elements — see the
frontmatter note */
transform: translateZ(0);
}
/* The frame is its own viewport, so the page inside scrolls within it. */
.screenshot .page {
height: 100%;
overflow: hidden;
}
</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="screenshot" id="screenshot" data-screenshot-width="1280">
<div class="page">
<Sidebar breakpoint="lg" folded={sidebarMode === 'folded'} foldedHover={sidebarMode === 'folded-hover'} pageMenu={pageMenu} />
<Navbar hideSearch pageMenu={pageMenu} />
<div class="page-wrapper">
<PageHeader title={headerTitle} description={description} />
<main id="content" class="page-body">
<div class="container-xl">
<slot />
</div>
</main>
</div>
</div>
{themeSettings && <ThemeSettings />}
</div>
<!-- The frame fills the viewport, so this toggle overlaps it; the capture
hides everything marked data-screenshot-chrome before taking the picture. -->
<nav class="nav-segmented position-fixed bottom-0 mb-4" data-screenshot-chrome>
<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>
{libJsFiles.map((src) => <script is:inline src={src} defer />)}
<script is:inline src={`/dist/js/tabler${min}.js`} defer></script>
<PageScripts />
<script>
// Signals to the screenshot tool that fonts and JS-driven component init
// are settled — same contract as ScreenshotLayout.
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>
+20 -1
View File
@@ -31,6 +31,12 @@ interface Props {
}
const { title, rtl = false, showLogo = true, columns, zoom = 1, pageLibs = [], cssPlugins = [], class: className } = Astro.props
// tabler-vendors.css carries the third-party styling (the ApexCharts tokens
// among them, which is what themes a chart's labels, grid and legend for the
// color mode). A page that pulls a lib in needs it, so it is added rather than
// left to every page to remember — the preview loads it on every page.
const stylePlugins = [...new Set([...(pageLibs.length > 0 ? ['vendors'] : []), ...cssPlugins])]
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
@@ -58,11 +64,24 @@ const libJsFiles = pageLibEntries.flatMap(([, lib]) => (lib.js ?? []).map((file)
<link href={`./dist/css/tabler${rtlSuffix}${min}.css`} rel="stylesheet" />
{cssPlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${rtlSuffix}${min}.css`} rel="stylesheet" />)}
{stylePlugins.map((plugin) => <link href={`./dist/css/tabler-${plugin}${rtlSuffix}${min}.css`} rel="stylesheet" />)}
{libCssFiles.map((href) => <link href={href} rel="stylesheet" />)}
<!-- Inter for the captures: Tabler ships system font stacks (no web font),
and the body's cv03/cv04/cv11 feature settings were tuned for Inter, so the
pictures show the face the design was drawn in — the same on every machine
that runs the capture. The serif and monospace theme variants keep their own
stacks; only the sans token is replaced. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style is:inline>
:root {
--tblr-font-sans-serif: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
/* Deterministic screenshots: no mid-capture animation/transition frames, no blinking caret. */
*,
*::before,
+1
View File
@@ -23,6 +23,7 @@
"@astrojs/check": "^0.9.4",
"@types/node": "^26.4.0",
"playwright": "1.62.1",
"sharp": "0.35.4",
"shx": "^0.4.0",
"typescript": "^5.7.3"
}
+52
View File
@@ -0,0 +1,52 @@
---
// `.bg-blur` (backdrop-filter) added in 1.5, in its most common home: a
// translucent caption bar over a photo. The image shows through the bar
// softened, and the bar's own text and controls stay sharp.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Avatar from '@ui/Avatar.astro'
import AvatarList from '@ui/AvatarList.astro'
import Subheader from '@ui/Subheader.astro'
const projects = [
{
photo: 'home-office-desk-with-macbook-iphone-calendar-watch-and-organizer.jpg',
title: 'Studio redesign',
meta: '24 photos · Updated 2 days ago',
people: ['avatars/000m.jpg', 'avatars/002m.jpg', 'avatars/003f.jpg'],
},
{
photo: 'elegant-home-office-with-golden-accessories.jpg',
title: 'Autumn lookbook',
meta: '61 photos · Updated today',
people: ['avatars/004m.jpg', 'avatars/006f.jpg'],
},
]
---
<ScreenshotLayout title="Background blur" columns={2} zoom={1.3}>
<div class="row row-cards">
{
projects.map((project) => (
<div class="col-6">
<div class="card overflow-hidden">
<div class="img-responsive img-responsive-4x3 card-img-top" style={`background-image: url(/static/photos/${project.photo})`} />
<div class="card-body position-absolute bottom-0 start-0 end-0 bg-blur" style="background-color: color-mix(in srgb, var(--tblr-bg-surface) 72%, transparent)">
<div class="d-flex align-items-center">
<div class="me-auto">
<Subheader>Album</Subheader>
<div class="fw-semibold">{project.title}</div>
<div class="text-secondary small">{project.meta}</div>
</div>
<AvatarList stacked>
{project.people.map((photo) => (
<Avatar src={`static/${photo}`} size="sm" />
))}
</AvatarList>
</div>
</div>
</div>
</div>
))
}
</div>
</ScreenshotLayout>
+65 -29
View File
@@ -1,48 +1,84 @@
---
// .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).
// .card-gradient + .card-gradient-{preset} come from core/scss/ui/_cards.scss
// (bundled in the base tabler.css — no cssPlugins needed here).
// Composition: one hero card that carries the gradient as emphasis — the
// workspace's plan, with price, what it includes and the upgrade action — and
// under it three small KPI cards from the same story, each on a different
// preset. The accent colors are picked per card rather than derived from the
// preset, so icons and bars stay legible on the tinted surface in dark mode.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Subheader from '@ui/Subheader.astro'
import Avatar from '@ui/Avatar.astro'
import Progress from '@ui/Progress.astro'
import Button from '@ui/Button.astro'
import Icon from '@ui/Icon.astro'
import Badge from '@ui/Badge.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 },
const plan = {
name: 'Pro',
price: '$29',
period: 'per seat / month',
includes: ['Unlimited projects', 'Advanced reports', 'Priority support'],
}
const kpis = [
{ preset: 'sun', accent: 'orange', icon: 'users', label: 'Seats', value: '48 / 60', progress: 80 },
{ preset: 'snow', accent: 'cyan', icon: 'database', label: 'Storage', value: '1.2 TB', progress: 60 },
{ preset: 'gold', accent: 'yellow', icon: 'calendar', label: 'Renews in', value: '12 days', progress: 35 },
]
---
<ScreenshotLayout title="Card gradient" columns={2}>
<div class="row row-cards">
<div class="col-12">
<div class="card card-gradient card-gradient-ocean">
<div class="card-body">
<div class="row g-4 align-items-center">
<div class="col">
<div class="d-flex align-items-center gap-2 mb-2">
<Subheader as="h4" class="mb-0">Current plan</Subheader>
<Badge text="Active" color="azure" />
</div>
<div class="d-flex align-items-baseline gap-2 mb-3">
<div class="h1 mb-0">{plan.name}</div>
<div class="h2 mb-0 fw-normal">{plan.price}</div>
<div class="text-secondary">{plan.period}</div>
</div>
<ul class="list-unstyled space-y-1 mb-0">
{
plan.includes.map((item) => (
<li class="d-flex align-items-center gap-2">
<Icon name="check" class="text-azure" />
<span>{item}</span>
</li>
))
}
</ul>
</div>
<div class="col-auto text-end">
<Button text="Upgrade to Business" color="azure" icon="arrow-up-right" />
<div class="text-secondary small mt-2">Next invoice: $1,392 on Oct 1</div>
</div>
</div>
</div>
</div>
</div>
{
variants.map((variant) => (
<div class="col-6">
<div class={`card card-gradient card-gradient-${variant.name}`}>
kpis.map((kpi) => (
<div class="col-4">
<div class={`card card-gradient card-gradient-${kpi.preset}`}>
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col">
<Subheader as="h4" class="mb-1">
{variant.label}
<div class="d-flex align-items-center gap-3 mb-3">
<Avatar icon={kpi.icon} color={kpi.accent} />
<div>
<Subheader as="h4" class="mb-0">
{kpi.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 class="h3 mb-0">{kpi.value}</div>
</div>
</div>
<Progress value={kpi.progress} color={kpi.accent} size="sm" />
</div>
</div>
</div>
+44
View File
@@ -0,0 +1,44 @@
---
// `funnel` — one of the chart types added in 1.5 (a bar chart with
// `funnel: true` in charts.json), one color per stage.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import Subheader from '@ui/Subheader.astro'
import Chart from '@components/demo/Chart.astro'
// First and last stage of the funnel data in charts.json, and their ratio.
const figures = [
{ label: 'Visitors', value: '1,380' },
{ label: 'Customers', value: '190' },
{ label: 'Conversion', value: '13.8%' },
]
---
<ScreenshotLayout title="Funnel chart" columns={2} pageLibs={['apexcharts']}>
<Card>
<CardHeader>
<div>
<CardTitle>Conversion funnel</CardTitle>
<CardSubtitle>From first visit to paying customer, this quarter</CardSubtitle>
</div>
<CardActions class="d-flex gap-4 text-end">
{
figures.map((figure) => (
<div>
<Subheader>{figure.label}</Subheader>
<div class="fw-semibold">{figure.value}</div>
</div>
))
}
</CardActions>
</CardHeader>
<CardBody>
<Chart chartId="funnel" height={16} label="Visitors converted through sign-ups, trials and demos into customers" />
</CardBody>
</Card>
</ScreenshotLayout>
+63
View File
@@ -0,0 +1,63 @@
---
// `radar` is one of the chart types added in 1.5 (charts.json `type: "radar"`),
// rendered through the same demo/Chart.astro wrapper as every other chart.
// Composition: the plot on the left, a legend column on the right that also
// carries each plan's average — so the card stays short. Each legend entry is
// a three-step stack: series name, the figure large and semibold with tabular
// numerals so the two averages align digit for digit, a muted uppercase label. `radar-large` extends
// the docs radar with an explicit `radar-size` (ApexCharts would leave wide
// margins otherwise) and without the built-in bottom legend.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import CardBody from '@ui/CardBody.astro'
import Legend from '@ui/Legend.astro'
import Subheader from '@ui/Subheader.astro'
import Chart from '@components/demo/Chart.astro'
// Series order and colors follow the `radar` entry in charts.json; the
// averages are computed from its data.
const plans = [
{ name: 'Pro', color: 'primary', average: 69 },
{ name: 'Enterprise', color: 'green', average: 83 },
]
---
<ScreenshotLayout title="Radar chart" columns={2} pageLibs={['apexcharts']}>
<Card>
<CardHeader>
<div>
<CardTitle>Plan comparison</CardTitle>
<CardSubtitle>Feature scores, 0–100, Pro against Enterprise</CardSubtitle>
</div>
</CardHeader>
<CardBody>
<div class="row g-4 align-items-center">
<div class="col">
<Chart chartId="radar-large" height={18} label="Pro and Enterprise plans compared across support, security, speed, storage, integrations and price" />
</div>
<div class="col-4">
<div class="space-y-4">
{
plans.map((plan) => (
<div>
<Legend color={plan.color} class="mb-1">
{plan.name}
</Legend>
<div class="h1 mb-0 lh-1" style="font-variant-numeric: tabular-nums">
{plan.average}
</div>
<Subheader as="div" class="mt-1">
Average score
</Subheader>
</div>
))
}
</div>
</div>
</div>
</CardBody>
</Card>
</ScreenshotLayout>
+43
View File
@@ -0,0 +1,43 @@
---
// `treemap` — one of the chart types added in 1.5. Each cell is shaded from the
// series color by value, so it reads in both color modes.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import Subheader from '@ui/Subheader.astro'
import Chart from '@components/demo/Chart.astro'
// Sum and largest share of the treemap data in charts.json.
const figures = [
{ label: 'Used', value: '791 GB' },
{ label: 'Largest', value: 'Documents · 28%' },
]
---
<ScreenshotLayout title="Treemap chart" columns={2} pageLibs={['apexcharts']}>
<Card>
<CardHeader>
<div>
<CardTitle>Storage by folder</CardTitle>
<CardSubtitle>Space used across the workspace</CardSubtitle>
</div>
<CardActions class="d-flex gap-4 text-end">
{
figures.map((figure) => (
<div>
<Subheader>{figure.label}</Subheader>
<div class="fw-semibold">{figure.value}</div>
</div>
))
}
</CardActions>
</CardHeader>
<CardBody>
<Chart chartId="treemap" height={16} label="Storage used per folder" />
</CardBody>
</Card>
</ScreenshotLayout>
+11
View File
@@ -0,0 +1,11 @@
---
// The CRM dashboard added in 1.5, with the cards it introduced: the stat row
// and the pipeline funnel, next to one of the advanced charts the release also
// brought in — a two-y-axes plot mixing columns and a line.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,11 @@
---
// The CRM dashboard under a different theme: Green accent on cool zinc grays with every corner squared off.
// Same screen as dashboard-crm.astro; only the data-bs-theme-* attributes on
// <html> change, the way the theme switcher sets them.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard — Green, sharp corners" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']} theme={{ 'theme-primary': 'green', 'theme-base': 'zinc', 'theme-radius': '0' }}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,11 @@
---
// The CRM dashboard under a different theme: Indigo accent, a serif face, warm stone grays and softer corners.
// Same screen as dashboard-crm.astro; only the data-bs-theme-* attributes on
// <html> change, the way the theme switcher sets them.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard — Indigo, serif" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']} theme={{ 'theme-primary': 'indigo', 'theme-font': 'serif', 'theme-base': 'stone', 'theme-radius': '1.5' }}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,11 @@
---
// The CRM dashboard under a different theme: Orange accent, a monospace face, neutral grays and tight corners.
// Same screen as dashboard-crm.astro; only the data-bs-theme-* attributes on
// <html> change, the way the theme switcher sets them.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard — Orange, monospace" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']} theme={{ 'theme-primary': 'orange', 'theme-font': 'monospace', 'theme-base': 'neutral', 'theme-radius': '0.5' }}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,11 @@
---
// The CRM dashboard under a different theme: Purple accent on bluish slate grays with the largest radius.
// Same screen as dashboard-crm.astro; only the data-bs-theme-* attributes on
// <html> change, the way the theme switcher sets them.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard — Purple, rounded" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']} theme={{ 'theme-primary': 'purple', 'theme-base': 'slate', 'theme-radius': '2' }}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,11 @@
---
// The CRM dashboard under a different theme: Teal accent on warm stone grays, everything else at the defaults.
// Same screen as dashboard-crm.astro; only the data-bs-theme-* attributes on
// <html> change, the way the theme switcher sets them.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmDashboard from '../components/CrmDashboard.astro'
---
<ScreenshotAppLayout title="CRM dashboard — Teal, stone" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" pageLibs={['apexcharts']} theme={{ 'theme-primary': 'teal', 'theme-base': 'stone' }}>
<CrmDashboard />
</ScreenshotAppLayout>
@@ -0,0 +1,33 @@
---
// The Change password modal added in 1.5 — strength meter, confirm check and
// show/hide toggles. Rendered inline (ModalInline + `show`) so a static capture
// shows the open dialog.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ModalInline from '@shared/components/modals/ModalInline.astro'
import ChangePasswordModalContent from '@shared/components/modals/ChangePasswordModalContent.astro'
---
<ScreenshotLayout title="Change password modal" columns={2}>
<ModalInline class="position-relative rounded d-block w-auto h-auto z-0" modalId="change-password" show>
<ChangePasswordModalContent />
</ModalInline>
<!-- A capture of an empty form says nothing about the strength meter, so fill the
fields in and let the modal's own `input` handler rate the password. Runs on
DOMContentLoaded — after the modal's module script has attached that handler. -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const values = {
'password-current': 'sunshine1',
'password-new': 'Tabler#1.5rocks',
'password-confirm': 'Tabler#1.5rocks',
}
for (const [id, value] of Object.entries(values)) {
const input = document.getElementById(id)
if (!input) continue
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
}
})
</script>
</ScreenshotLayout>
+30
View File
@@ -0,0 +1,30 @@
---
// The New task modal added in 1.5. Rendered inline (ModalInline + `show`) so a
// static capture shows the open dialog; litepicker and tom-select back the due
// date and the assignee/tags selects.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ModalInline from '@shared/components/modals/ModalInline.astro'
import NewTaskModalContent from '@shared/components/modals/NewTaskModalContent.astro'
---
<ScreenshotLayout title="New task modal" columns={2} pageLibs={['litepicker', 'tom-select']}>
<ModalInline class="position-relative rounded d-block w-auto h-auto z-0" modalId="new-task" size="lg" show>
<NewTaskModalContent />
</ModalInline>
<!-- An empty form makes a poor screenshot: fill the text fields and the tags
in once the modal and its plugins are in the DOM. -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const name = document.getElementById('task-name')
const description = document.getElementById('task-description')
if (name) name.value = 'Prepare the 1.5 release notes'
if (description) description.value = 'Collect the changesets, group them by package and draft the announcement post.'
// DataSelect drops the demo `value` from selects.json, so the tags field
// renders empty — pick two through TomSelect's own API instead. Deferred
// by a timeout because Select.astro builds the instance in its own
// DOMContentLoaded handler, which runs after this one.
setTimeout(() => window.tabler_select?.['select-task-category']?.addItems(['HTML', 'Bootstrap']), 0)
})
</script>
</ScreenshotLayout>
+45
View File
@@ -0,0 +1,45 @@
---
// The language selector added to the navbar in 1.5, shown open next to the
// other navbar side items.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmStats from '@shared/components/cards/CrmStats.astro'
import TopDeals from '@shared/components/cards/TopDeals.astro'
---
<ScreenshotAppLayout title="Language selector" pageHeader="CRM Dashboard" pageMenu="dashboards.crm" navbarPosition="horizontal">
<div class="row row-cards">
<div class="col-6 col-lg-3">
<CrmStats color="primary" icon="currency-dollar" lt title="MRR" description="$92.4K" changeValue="+9.5" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="azure" icon="filter" lt title="Pipeline" description="$2.4M" changeValue="+14" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="green" icon="circle-check" lt title="Deals won" description="74" changeValue="+8" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="purple" icon="percentage" lt title="Win rate" description="38%" changeValue="+0.4" />
</div>
<div class="col-12">
<TopDeals />
</div>
</div>
<!-- The full language list is taller than the frame; cap it so the open menu
ends inside the picture. -->
<style is:inline>
.dropdown-menu:has([data-lang]) {
max-height: 28rem;
overflow-y: auto;
}
</style>
<!-- The open dropdown is the subject of this capture, so it is opened here
rather than left to a click the capture tool cannot make. -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.querySelector('[aria-label="Select language"]')
if (toggle && window.tabler?.Dropdown) window.tabler.Dropdown.getOrCreateInstance(toggle).show()
})
</script>
</ScreenshotAppLayout>
+65
View File
@@ -0,0 +1,65 @@
---
// The progress pieces added in 1.5 — `.progress-steps`, the `.progress-lg`
// size and `.progressbg` — shown as one product card rather
// than a row of labelled samples: an onboarding tracker, then two reporting
// sections under it, each split off by the card body divider. Everything
// stays on the primary color in its light tints, so nothing shouts.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import CardActions from '@ui/CardActions.astro'
import CardBody from '@ui/CardBody.astro'
import Subheader from '@ui/Subheader.astro'
import ProgressSteps from '@ui/ProgressSteps.astro'
import ProgressBg from '@ui/ProgressBg.astro'
import ProgressDescription from '@ui/ProgressDescription.astro'
const steps = ['Create workspace', 'Invite team', 'Connect data source', 'Set up billing', 'Go live']
const active = 3
const regions = [
{ name: 'Poland', value: 85 },
{ name: 'Germany', value: 65 },
{ name: 'United States', value: 45 },
]
---
<ScreenshotLayout title="Progress steps" columns={2}>
<Card>
<CardHeader>
<div>
<CardTitle>Workspace setup</CardTitle>
<CardSubtitle>Finish these steps to open the workspace to your team</CardSubtitle>
</div>
<CardActions class="text-end">
<Subheader>Completed</Subheader>
<div class="fw-semibold">{active} of {steps.length}</div>
</CardActions>
</CardHeader>
<CardBody>
<ProgressSteps labels={steps} active={active} ariaLabel="Setup progress" />
<div class="d-flex justify-content-between mt-2 text-secondary">
<span>
Step {active} · {steps[active - 1]}
</span>
<span>Next: {steps[active]}</span>
</div>
</CardBody>
<CardBody>
<Subheader class="mb-3">Adoption by region</Subheader>
<div class="space-y-2">{regions.map((region) => <ProgressBg value={region.value} text={region.name} showValue />)}</div>
</CardBody>
<CardBody>
<Subheader class="mb-3">Capacity</Subheader>
<div class="space-y-3">
<ProgressDescription label="Seats" description="48 of 60 used" value={80} color="primary" size="lg" />
<ProgressDescription label="Storage" description="1.2 TB of 2 TB" value={60} color="primary" size="lg" />
</div>
</CardBody>
</Card>
</ScreenshotLayout>
+44
View File
@@ -0,0 +1,44 @@
---
// The folded sidebar added in 1.5 (`navbar-folded`): an icon-only rail with the
// pinned brand on top, the section separators in place of the group labels and
// the user block at the bottom.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmStats from '@shared/components/cards/CrmStats.astro'
import MrrOverview from '@shared/components/cards/charts/MrrOverview.astro'
---
<ScreenshotAppLayout title="Folded sidebar" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" sidebarMode="folded" pageLibs={['apexcharts']}>
<div class="row row-cards">
<div class="col-6 col-lg-3">
<CrmStats color="primary" icon="currency-dollar" lt title="MRR" description="$92.4K" changeValue="+9.5" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="azure" icon="filter" lt title="Pipeline" description="$2.4M" changeValue="+14" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="green" icon="circle-check" lt title="Deals won" description="74" changeValue="+8" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="purple" icon="percentage" lt title="Win rate" description="38%" changeValue="+0.4" />
</div>
<div class="col-12">
<MrrOverview />
</div>
</div>
<!-- The flyout submenu is what the folded rail trades the labels for, so it
is opened here rather than left to a hover the capture tool cannot make. -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
// A group low in the rail, so the flyout lands over the empty part of the
// page instead of covering the cards.
const items = [...document.querySelectorAll('.navbar-vertical .navbar-nav > .nav-item')]
const toggle = items.find((item) => item.textContent?.trim().startsWith('Addons'))?.querySelector('[data-bs-toggle="dropdown"]')
if (!toggle || !window.tabler?.Dropdown) return
// Deferred by a timeout: opened straight from this handler the menu is
// closed again by the framework's own DOMContentLoaded wiring.
setTimeout(() => window.tabler.Dropdown.getOrCreateInstance(toggle).show(), 0)
})
</script>
</ScreenshotAppLayout>
+51
View File
@@ -0,0 +1,51 @@
---
// The theme settings panel, which gained the layout section in 1.5: navbar
// position, container width, sidebar mode and the navbar theme, next to the
// appearance presets and the new `auto` color mode.
import ScreenshotAppLayout from '../layouts/ScreenshotAppLayout.astro'
import CrmStats from '@shared/components/cards/CrmStats.astro'
import TopDeals from '@shared/components/cards/TopDeals.astro'
---
<ScreenshotAppLayout title="Theme settings" pageHeader="CRM Dashboard" description="Welcome back — here's your CRM overview for today." pageMenu="dashboards.crm" themeSettings>
<div class="row row-cards">
<div class="col-6 col-lg-3">
<CrmStats color="primary" icon="currency-dollar" lt title="MRR" description="$92.4K" changeValue="+9.5" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="azure" icon="filter" lt title="Pipeline" description="$2.4M" changeValue="+14" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="green" icon="circle-check" lt title="Deals won" description="74" changeValue="+8" />
</div>
<div class="col-6 col-lg-3">
<CrmStats color="purple" icon="percentage" lt title="Win rate" description="38%" changeValue="+0.4" />
</div>
<div class="col-12">
<TopDeals />
</div>
</div>
<!-- The panel is the subject of this capture, so it is opened here rather
than left to a click the capture tool cannot make. -->
<script is:inline>
document.addEventListener('DOMContentLoaded', () => {
const panel = document.getElementById('offcanvas-settings')
if (!panel || !window.tabler?.Offcanvas) return
window.tabler.Offcanvas.getOrCreateInstance(panel, { backdrop: false, scroll: true }).show()
// Its own backdrop, not the framework's: Bootstrap appends that one to
// <body>, outside the transformed capture frame, where it would cover the
// panel too. Rendered as a sibling of the panel it dims the page behind
// it and stays under it, the way an opened panel really looks.
const backdrop = document.createElement('div')
backdrop.className = 'offcanvas-backdrop show'
panel.parentElement?.insertBefore(backdrop, panel)
// The layout settings are the 1.5 addition and they sit below the fold of
// the panel — scroll their section to the top of the panel.
panel.querySelector('[data-theme-setting="navbar-position"]')?.closest('section')?.scrollIntoView({ block: 'start' })
})
</script>
</ScreenshotAppLayout>