diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 693b2ae10..013190d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,9 @@ importers: playwright: specifier: 1.62.1 version: 1.62.1 + sharp: + specifier: 0.35.4 + version: 0.35.4(@types/node@26.4.0) shx: specifier: ^0.4.0 version: 0.4.0 @@ -10201,7 +10204,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 '@types/node': 26.4.0 - optional: true shebang-command@1.2.0: dependencies: diff --git a/screenshots/.build/capture.ts b/screenshots/.build/capture.ts index 748cf83f9..e5e647782 100644 --- a/screenshots/.build/capture.ts +++ b/screenshots/.build/capture.ts @@ -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 { +function runAstro(args: string[]): Promise { 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 { + 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() } } diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 000000000..9dc57f2ab --- /dev/null +++ b/screenshots/README.md @@ -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. diff --git a/screenshots/components/CrmDashboard.astro b/screenshots/components/CrmDashboard.astro new file mode 100644 index 000000000..29e761f9c --- /dev/null +++ b/screenshots/components/CrmDashboard.astro @@ -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' }, +] +--- + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+ Revenue vs orders + Monthly revenue against order volume, last 12 months +
+ + { + figures.map((figure) => ( +
+ {figure.label} +
{figure.value}
+
+ )) + } +
+
+ + + +
+
+
+ +
+
+ +
+
diff --git a/screenshots/layouts/ScreenshotAppLayout.astro b/screenshots/layouts/ScreenshotAppLayout.astro new file mode 100644 index 000000000..0792e4847 --- /dev/null +++ b/screenshots/layouts/ScreenshotAppLayout.astro @@ -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 — `theme-primary`, + * `theme-font`, `theme-base`, `theme-radius` (see themeDefaults). Pulls in + * tabler-themes.css, which holds the variants. + */ + theme?: Partial> + /** 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 . +const themeAttributes = themeDataAttributes({ ...theme, 'navbar-position': navbarPosition, 'layout': layout, 'sidebar': sidebarMode } as Partial>) + +// 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).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))) +--- + + + + + + + + {title} - Tabler screenshots + + + + + + {stylePlugins.map((plugin) => )} + + {libCssFiles.map((href) => )} + + + + + + + + + + + + +
+
+ + + + +
+ + +
+
+ +
+
+
+
+ + {themeSettings && } +
+ + + + + {libJsFiles.map((src) => + + + + + + diff --git a/screenshots/layouts/ScreenshotLayout.astro b/screenshots/layouts/ScreenshotLayout.astro index cb564257f..039848534 100644 --- a/screenshots/layouts/ScreenshotLayout.astro +++ b/screenshots/layouts/ScreenshotLayout.astro @@ -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) - {cssPlugins.map((plugin) => )} + {stylePlugins.map((plugin) => )} {libCssFiles.map((href) => )} + + + + + + + + + diff --git a/screenshots/pages/progress-steps.astro b/screenshots/pages/progress-steps.astro new file mode 100644 index 000000000..1d59100ff --- /dev/null +++ b/screenshots/pages/progress-steps.astro @@ -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 }, +] +--- + + + + +
+ Workspace setup + Finish these steps to open the workspace to your team +
+ + Completed +
{active} of {steps.length}
+
+
+ + + +
+ + Step {active} · {steps[active - 1]} + + Next: {steps[active]} +
+
+ + + Adoption by region +
{regions.map((region) => )}
+
+ + + Capacity +
+ + +
+
+
+
diff --git a/screenshots/pages/sidebar-folded.astro b/screenshots/pages/sidebar-folded.astro new file mode 100644 index 000000000..15346ffaf --- /dev/null +++ b/screenshots/pages/sidebar-folded.astro @@ -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' +--- + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
diff --git a/screenshots/pages/theme-settings.astro b/screenshots/pages/theme-settings.astro new file mode 100644 index 000000000..9bf21fee8 --- /dev/null +++ b/screenshots/pages/theme-settings.astro @@ -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' +--- + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
diff --git a/shared/data/charts.json b/shared/data/charts.json index bf30491bb..5c04ff1d9 100644 --- a/shared/data/charts.json +++ b/shared/data/charts.json @@ -771,6 +771,12 @@ ], "name": "Plan comparison" }, + "radar-large": { + "extend": "radar", + "demo": false, + "legend": false, + "radar-size": 115 + }, "polar-area": { "demo": true, "type": "polarArea", @@ -944,6 +950,10 @@ } ] }, + "revenue-vs-orders-plain": { + "extend": "revenue-vs-orders", + "y-axes": [{ "series-name": "Revenue" }, { "series-name": "Orders", "opposite": true }] + }, "uptime-sla": { "type": "line", "categories": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], diff --git a/shared/lib/chart-script.ts b/shared/lib/chart-script.ts index 1536fb91c..1f41b8ca7 100644 --- a/shared/lib/chart-script.ts +++ b/shared/lib/chart-script.ts @@ -54,6 +54,8 @@ export type ChartData = { 'radial-hollow-margin'?: number 'radial-hollow-size'?: string 'radial-labels-show'?: boolean + /** radar radius in px; ApexCharts picks one from the box when absent, leaving wide margins in a tall box */ + 'radar-size'?: number 'radial-name-show'?: boolean 'radial-name-offset-y'?: number 'radial-name-font-size'?: string @@ -269,6 +271,7 @@ export function chartConfig(opts: { id: string; data: ChartData; height: number if (type === 'radar') { config.plotOptions = { radar: { + size: data['radar-size'], polygons: { // The rings are the one piece of chrome the --apx-grid token does not reach. strokeColors: 'var(--apx-grid)',