From e89bc190965fe009fb4935967325d9a5fdc7be9e Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 6 Oct 2026 18:06:00 +0200 Subject: [PATCH 1/5] Fix dev server directory events and output cleanup races. --- .../src/build/incremental/incremental.test.ts | 34 ++++++++++ .../ts-ssg/src/build/incremental/index.ts | 40 +++++++++++- packages/ts-ssg/src/dev/server.ts | 63 +++++++++++++------ 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index 3142e54c..0b6fcbf9 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -59,6 +59,40 @@ describe('incremental builder', () => { await logger?.close() }) + it('ignores directory notifications and refreshes removed subtrees without deleting directories as files', async () => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + const assetsDir = path.join(contentDir, 'assets') + const emptyDir = path.join(contentDir, 'empty') + await fs.mkdir(assetsDir, { recursive: true }) + await fs.mkdir(emptyDir) + await fs.mkdir(path.join(outDir, 'empty'), { recursive: true }) + await fs.writeFile(path.join(assetsDir, 'example.txt'), 'asset content') + await fs.writeFile(path.join(contentDir, 'index.mdx'), '# Home') + const builder = await createIncrementalBuilder({ + siteConfig: { rootDir: base, contentDir, outDir }, + }) + await builder.prepareForRequests() + await builder.prepareAssetByUrlPath('/assets/example.txt') + expect((await builder.applyChange(assetsDir)).fullRebuild).toBe(false) + expect((await builder.applyChange(contentDir)).fullRebuild).toBe(false) + expect( + await fs.readFile(path.join(outDir, 'assets', 'example.txt'), 'utf8'), + ).toBe('asset content') + await fs.rmdir(emptyDir) + expect((await builder.applyChange(emptyDir)).fullRebuild).toBe(false) + expect((await fs.stat(path.join(outDir, 'empty'))).isDirectory()).toBe( + true, + ) + await fs.rm(assetsDir, { recursive: true, force: true }) + expect((await builder.applyChange(assetsDir)).fullRebuild).toBe(true) + expect( + await fs.readFile(path.join(outDir, 'assets', 'example.txt'), 'utf8'), + ).toBe('asset content') + }) + }) + it('removes deleted content outputs and updates manifest', async () => { await withTempDir(async (base) => { const contentDir = path.join(base, 'content') diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index 072ab899..a09317fb 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -1,3 +1,4 @@ +import fsPromises from 'node:fs/promises' import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' import { themes } from '@purestack/ts-style' @@ -475,13 +476,37 @@ class IncrementalRuntime { const result = this.changeApplier.createResult( `content change: ${filePath}`, ) - if (isOutsideContentRoot(relPath)) return result + if (!relPath || isOutsideContentRoot(relPath)) return result + const sourceStats = await fsPromises + .stat(filePath) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') + return undefined + throw error + }) + // Recursive watchers also emit directory events. They are not asset files. + if (sourceStats?.isDirectory()) return result if (isSiteConfigFile(relPath)) { result.fullRebuild = true return result } await this.ensureContentReady() + if (!sourceStats) { + const prefix = `${toPosixPath(relPath)}/` + const removedDirectory = + this.context.contentRoutes.pages.some((file) => + toPosixPath(file.relPath).startsWith(prefix), + ) || + [...this.staticAssets.values()].some((file) => + toPosixPath(file.relPath).startsWith(prefix), + ) + if (removedDirectory) { + // Refresh discovery for a removed subtree; never rm its output as a file. + result.fullRebuild = true + return result + } + } this.contentState.takeMarkedPageCount() const ext = path.extname(relPath).toLowerCase() if ( @@ -494,9 +519,18 @@ class IncrementalRuntime { path.basename(relPath).toUpperCase() !== DEFAULT_NAV_FILENAME.toUpperCase() ) { - await removeFile( - path.join(this.config.outDir, toOutputAssetRelPath(relPath)), + const cachedAsset = this.manifest.assets[relPath] + const knownAsset = this.staticAssets.get( + `/${toPosixPath(toOutputAssetRelPath(relPath))}`, ) + // A missing, empty directory can also arrive as an event. Only invalidate + // output for paths previously discovered or copied as actual files. + if (cachedAsset || knownAsset) { + await removeFile( + cachedAsset?.outPath ?? + path.join(this.config.outDir, toOutputAssetRelPath(relPath)), + ) + } delete this.manifest.assets[relPath] await this.discoverRequestAssets() this.contentState.refreshAssets( diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index f513cbca..83ee4d6a 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -96,7 +96,21 @@ export async function startDevServer( const { host, port, watch, liveReload, fullRender } = resolveDevServerOptions(input) - let fullBuild: Promise | undefined + let setupReady: Promise = Promise.resolve() + const activeResponses = new Set>() + const beginSiteRequest = async () => { + while (true) { + const ready = setupReady + await ready + if (ready !== setupReady) continue + const finished = Promise.withResolvers() + activeResponses.add(finished.promise) + return () => { + activeResponses.delete(finished.promise) + finished.resolve() + } + } + } const clients: LiveReloadClients = new Map() let liveReloadVersion = 0 @@ -169,7 +183,13 @@ export async function startDevServer( reason: string, options?: { recreateBuilder?: boolean; reloadConfig?: boolean }, ) => { + const previousSetup = setupReady + const prepared = Promise.withResolvers() + setupReady = prepared.promise try { + await previousSetup + // Output cleanup must not race HTML reads or streamed asset responses. + await Promise.all([...activeResponses]) if (options?.reloadConfig) { buildInput = await projectConfig.load(baseBuildInput) watchConfigDependencies() @@ -181,8 +201,7 @@ export async function startDevServer( devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? []) } if (fullRender && !initialSetupDone) { - fullBuild = incremental.buildAll(reason) - await fullBuild + await incremental.buildAll(reason) } await incremental.prepareForRequests() if (!initialSetupDone) { @@ -194,6 +213,8 @@ export async function startDevServer( notifyReload(reason) } catch (error) { logError(log, error, 'dev setup failed') + } finally { + prepared.resolve() } } @@ -248,9 +269,7 @@ export async function startDevServer( basePath: config.basePath, i18n: config.i18n, liveReload, - waitForFullBuild: async () => { - if (fullRender) await fullBuild - }, + beginSiteRequest, // Config changes replace the builder, so requests ask for the current one. getIncremental: () => incremental, handlePluginRequest: (req, res) => devMiddleware(req, res), @@ -267,16 +286,6 @@ export async function startDevServer( }) server.keepAliveTimeout = 1000 server.headersTimeout = 5000 - server.listen(port, host, () => { - log.info('dev server listening', { - host, - port, - outDir: config.outDir, - liveReload, - watch, - fullRender, - }) - }) if (watch) { watcher = await watchTree(config.contentDir, (filePath) => { @@ -287,6 +296,16 @@ export async function startDevServer( watchConfigDependencies() void requestRebuild() + server.listen(port, host, () => { + log.info('dev server listening', { + host, + port, + outDir: config.outDir, + liveReload, + watch, + fullRender, + }) + }) const shutdown = async () => { if (shuttingDown) return @@ -376,7 +395,7 @@ type DevServerRequestHandlerInput = { basePath: string i18n: I18nConfig liveReload: boolean - waitForFullBuild: () => Promise + beginSiteRequest: () => Promise<() => void> getIncremental: () => IncrementalBuilder /** Resolves true when a plugin's dev middleware responded. */ handlePluginRequest: ( @@ -396,7 +415,7 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { basePath, i18n, liveReload, - waitForFullBuild, + beginSiteRequest, getIncremental, handlePluginRequest, clients, @@ -441,7 +460,13 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { let fileResult: Awaited> let incremental: IncrementalBuilder try { - await waitForFullBuild() + const finishRequest = await beginSiteRequest() + if (res.destroyed) { + finishRequest() + return + } + res.once('finish', finishRequest) + res.once('close', finishRequest) incremental = getIncremental() res.setTimeout(REQUEST_TIMEOUT_MS, () => { res.destroy() From b1831aa6c85b442a72c2b8c71819b3ff10a2e145 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 6 Oct 2026 18:16:16 +0200 Subject: [PATCH 2/5] Bump version. --- package.json | 3 +- packages/purestack/package.json | 2 +- packages/purestack/src/index.ts | 2 +- packages/ts-common/package.json | 2 +- packages/ts-components/package.json | 2 +- packages/ts-css/package.json | 2 +- packages/ts-html/package.json | 2 +- packages/ts-minidom/package.json | 2 +- packages/ts-page-scripts/package.json | 2 +- packages/ts-render/package.json | 2 +- packages/ts-ssg-vscode/package.json | 2 +- packages/ts-ssg/package.json | 2 +- packages/ts-ssg/src/config/head.ts | 2 +- packages/ts-style/package.json | 2 +- packages/ts-svg-icons/package.json | 2 +- packages/ts-util-node/package.json | 2 +- packages/ts-util/package.json | 2 +- scripts/bump-version.ts | 81 +++++++++++++++++++++++++++ scripts/sync-purestack-version.ts | 51 ----------------- 19 files changed, 99 insertions(+), 68 deletions(-) create mode 100644 scripts/bump-version.ts delete mode 100644 scripts/sync-purestack-version.ts diff --git a/package.json b/package.json index 1e031ed5..6dc70700 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, @@ -43,6 +43,7 @@ "lint:ci": "yarn workspaces foreach --no-private --all --verbose --topological -- exec biome check ./src --formatter-enabled=false", "check-eol": "yarn tsx scripts/check-eol.ts", "sync-purestack-version": "yarn tsx --no-warnings scripts/sync-purestack-version.ts", + "bump-version": "yarn tsx --no-warnings scripts/bump-version.ts", "bundle": "yarn tsx --no-warnings scripts/bundle.ts", "cleanup": "yarn tsx --no-warnings scripts/cleanup.ts", "publish-new-packages": "yarn tsx --no-warnings scripts/publish-new-packages.ts", diff --git a/packages/purestack/package.json b/packages/purestack/package.json index f0831c32..e9ec7668 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 8dfc4a2e..886347c5 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.4' +export const version: string = '1.1.5' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index de08f5f8..10dd71d2 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 0a4088f2..2852c654 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index 6d5d5e25..80f26f57 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 2b0108cd..0d140a33 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 383240da..0f9af5c7 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index e079f541..33acf742 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index 14f58032..0ab9da99 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index a569d0c9..ced25e7e 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.4", + "version": "1.1.5", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 88d697b6..1f2a3a3d 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 8a6df37c..c60886fd 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -25,7 +25,7 @@ export function getHead(config?: BasicHeadConfig) { const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.4', + content: 'PureStack v1.1.5', }), h('meta').attr({ name: 'color-scheme', diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index ae7b668d..ce07b5ee 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 6fc9f828..6fe59e1d 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.2", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index 702cf7c9..306ee0e0 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index c67ed165..add83482 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.4", + "version": "1.1.5", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts", diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts new file mode 100644 index 00000000..c2b5da88 --- /dev/null +++ b/scripts/bump-version.ts @@ -0,0 +1,81 @@ +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +) +if (process.argv.length > 2) { + throw new Error('Usage: yarn bump-version (no arguments)') +} + +const versionPattern = /^(0|[1-9]\d*)\.([0-9])\.([0-9])$/ +const rootManifest = JSON.parse( + readFileSync(path.join(projectRoot, 'package.json'), 'utf8'), +) as { version: string } +const currentVersion = rootManifest.version +const current = versionPattern.exec(currentVersion) +if (!current) + throw new Error( + `Expected a root version with single-digit minor and patch numbers, received ${currentVersion}.`, + ) + +// Treat minor and patch as decimal digits, carrying into the next component. +const [major, minor, patch] = current.slice(1).map(BigInt) +const incremented = major * 100n + minor * 10n + patch + 1n +const nextVersion = `${incremented / 100n}.${(incremented / 10n) % 10n}.${incremented % 10n}` + +type Update = { relativePath: string; source: string } +const updates: Update[] = [] + +function prepareUpdate(relativePath: string, pattern: RegExp) { + const source = readFileSync(path.join(projectRoot, relativePath), 'utf8') + if ([...source.matchAll(pattern)].length !== 1) { + throw new Error( + `Expected exactly one version declaration in ${relativePath}. No files have been changed.`, + ) + } + const updated = source.replace( + pattern, + (_match, prefix: string, _version: string, suffix: string) => + `${prefix}${nextVersion}${suffix}`, + ) + if (updated !== source) updates.push({ relativePath, source: updated }) +} + +const manifests = [ + 'package.json', + ...globSync('packages/*/package.json', { cwd: projectRoot }).sort(), +] +for (const manifestPath of manifests) { + const manifest = JSON.parse( + readFileSync(path.join(projectRoot, manifestPath), 'utf8'), + ) as { version?: string } + if (!manifest.version || !versionPattern.test(manifest.version)) { + throw new Error( + `Missing or invalid version in ${manifestPath}. No files have been changed.`, + ) + } + prepareUpdate(manifestPath, /^(\s*"version"\s*:\s*")([^"\r\n]+)(")/gm) +} + +prepareUpdate( + 'packages/purestack/src/index.ts', + /(export\s+const\s+version(?:\s*:\s*string)?\s*=\s*['"])([^'"\r\n]+)(['"])/g, +) +prepareUpdate( + 'packages/ts-ssg/src/config/head.ts', + /(content:\s*['"]PureStack v)([^'"\r\n]+)(['"])/g, +) + +// Read and validate every target before writing; keep formatting and line endings. +for (const update of updates) { + writeFileSync( + path.join(projectRoot, update.relativePath), + update.source, + 'utf8', + ) + console.log(`Updated ${update.relativePath}`) +} +console.log(`${currentVersion} -> ${nextVersion}: ${updates.length} file(s)`) diff --git a/scripts/sync-purestack-version.ts b/scripts/sync-purestack-version.ts deleted file mode 100644 index cdcad97c..00000000 --- a/scripts/sync-purestack-version.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs' -import path from 'node:path' - -interface PackageJson { - version?: string -} - -const projectRoot = process.cwd() -const packageJsonPath = path.join( - projectRoot, - 'packages', - 'purestack', - 'package.json', -) -const indexPath = path.join( - projectRoot, - 'packages', - 'purestack', - 'src', - 'index.ts', -) -const versionExportPattern = - /export\s+const\s+version(?:\s*:\s*string)?\s*=\s*['"]([^'"]+)['"]/ - -const packageJson = JSON.parse( - readFileSync(packageJsonPath, 'utf8'), -) as PackageJson - -if (!packageJson.version) { - throw new Error('packages/purestack/package.json is missing version.') -} - -const source = readFileSync(indexPath, 'utf8') -const match = versionExportPattern.exec(source) -if (!match) { - throw new Error('packages/purestack/src/index.ts is missing version export.') -} - -const sourceVersion = match[1] -if (sourceVersion === packageJson.version) { - process.exit(0) -} - -const nextSource = source.replace(versionExportPattern, (statement) => - statement.replace(/(['"])([^'"]+)(['"])$/, `$1${packageJson.version}$3`), -) - -writeFileSync(indexPath, nextSource, 'utf8') -console.log( - `purestack version export updated: ${sourceVersion} -> ${packageJson.version}`, -) From 0d130acfbf0fbc75f9c8d86bcfb12949c1cbd875 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 6 Oct 2026 18:19:57 +0200 Subject: [PATCH 3/5] Upgrade transitive dependencies to fix vulnerabilities. --- yarn.lock | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index a81622c0..6ca9b3c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4443,12 +4443,12 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^3.0.1": - version: 3.0.1 - resolution: "mkdirp@npm:3.0.1" - bin: - mkdirp: dist/cjs/src/bin.js - checksum: 10c0/9f2b975e9246351f5e3a40dcfac99fcd0baa31fbfab615fe059fb11e51f10e4803c63de1f384c54d656e4db31d000e4767e9ef076a22e12a641357602e31d57d +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec languageName: node linkType: hard @@ -5416,16 +5416,15 @@ __metadata: linkType: hard "tar@npm:^7.4.3": - version: 7.4.3 - resolution: "tar@npm:7.4.3" + version: 7.5.22 + resolution: "tar@npm:7.5.22" dependencies: "@isaacs/fs-minipass": "npm:^4.0.0" chownr: "npm:^3.0.0" minipass: "npm:^7.1.2" - minizlib: "npm:^3.0.1" - mkdirp: "npm:^3.0.1" + minizlib: "npm:^3.1.0" yallist: "npm:^5.0.0" - checksum: 10c0/d4679609bb2a9b48eeaf84632b6d844128d2412b95b6de07d53d8ee8baf4ca0857c9331dfa510390a0727b550fd543d4d1a10995ad86cdf078423fbb8d99831d + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea languageName: node linkType: hard From b8eefbffae55be4cd8f5fcdb11b33ec906385bb3 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 6 Oct 2026 19:33:56 +0200 Subject: [PATCH 4/5] Update grid and flex docs. --- frontend/docs/componentGuide.ts | 6 + .../components/layout/flex/flex.mdx | 22 + .../components/layout/flex/playground.ts | 447 ++++++++++++++++++ .../components/layout/grid/grid.mdx | 22 + .../components/layout/grid/playground.ts | 422 +++++++++++++++++ .../guides/styling/utility-css-classes.mdx | 3 + packages/ts-style/src/utilities.ts | 5 + 7 files changed, 927 insertions(+) create mode 100644 frontend/purestack.studio/components/layout/flex/playground.ts create mode 100644 frontend/purestack.studio/components/layout/grid/playground.ts diff --git a/frontend/docs/componentGuide.ts b/frontend/docs/componentGuide.ts index 193cd263..6639d220 100644 --- a/frontend/docs/componentGuide.ts +++ b/frontend/docs/componentGuide.ts @@ -121,6 +121,12 @@ export function registerComponentGuideStyles() { .select('.layout-demo-item--tall') .css({ minHeight: '4rem', fontSize: '1.3rem' }) root.select('.layout-demo-scroll').css({ overflowX: 'auto', minWidth: '0' }) + root.select('.layout-playground-frame').css({ height: '20rem' }) + root.select('.layout-playground-frame--compact').css({ height: '14rem' }) + root.select('.layout-playground-frame--tall').css({ height: '28rem' }) + root.select('.layout-playground-width--narrow').css({ width: '24rem' }) + root.select('.layout-playground-width--medium').css({ width: '40rem' }) + root.select('.layout-playground-width--wide').css({ width: '60rem' }) root .select('.layout-demo-track--narrow') .css({ width: '9rem', minHeight: '7rem' }) diff --git a/frontend/purestack.studio/components/layout/flex/flex.mdx b/frontend/purestack.studio/components/layout/flex/flex.mdx index 440e733c..0df1b3ea 100644 --- a/frontend/purestack.studio/components/layout/flex/flex.mdx +++ b/frontend/purestack.studio/components/layout/flex/flex.mdx @@ -40,6 +40,28 @@ nav: ``` +## Interactive playground + +Start with a preset, then explore direction, distribution, alignment, and wrapping. **Shared baseline** uses the framework's typography utilities to make baseline alignment visible. **Flexible first item** uses `flex-1` to show how item growth consumes free space; **Wrapping cards** makes line wrapping easy to compare with nowrap and reverse wrapping. Item sizing uses the component's `flex-none`, `flex-auto`, `flex-fill`, and `flex-1` utilities. + +Change the preview width and height to explore both axes. Responsive overrides follow the browser viewport, so resize your browser to cross a breakpoint. Open **Responsive overrides** to configure direction, alignment, distribution, and wrapping independently at each breakpoint. The live readout shows the active settings, and the generated markup includes your component props and utility classes. + + + +
+ +
+
+ +```html + +``` + + + + +
+ ## A responsive toolbar Resize the viewport: the title and action stack first, then share a row. diff --git a/frontend/purestack.studio/components/layout/flex/playground.ts b/frontend/purestack.studio/components/layout/flex/playground.ts new file mode 100644 index 00000000..10a7ad5b --- /dev/null +++ b/frontend/purestack.studio/components/layout/flex/playground.ts @@ -0,0 +1,447 @@ +import { + defineBadgeComponents, + defineButtonComponents, + defineFlexComponents, + defineFormComponents, + defineFormSelectField, + defineGridComponents, + defineIconComponents, + definePanelComponents, + type FormSelectOption, +} from '@purestack/ts-components' +import { BREAKPOINTS } from '@purestack/ts-style' +import { lucide_chevron_down } from '@purestack/ts-svg-icons' +import { + batch, + computed, + createApp, + defineComponent, + html, + onMounted, + onUnmounted, + ref, +} from 'regor' + +const names = ['sm', 'md', 'lg', 'xl'] as const +const options = (values: string[]): FormSelectOption[] => + values.map((value) => ({ label: value, value })) +const directionOptions = options([ + 'row', + 'row-reverse', + 'column', + 'column-reverse', +]) +const alignOptions = options(['stretch', 'start', 'center', 'end', 'baseline']) +const justifyOptions = options([ + 'start', + 'center', + 'end', + 'between', + 'around', + 'evenly', + 'stretch', +]) +const wrapOptions = options(['nowrap', 'wrap', 'reverse']) +const inherit = (values: FormSelectOption[]) => [ + { label: 'Inherit smaller breakpoint', value: '' }, + ...values, +] +const samples = [ + { title: 'Discover', tone: 'accent' }, + { title: 'Compose', tone: 'info' }, + { title: 'Refine', tone: 'feature' }, + { title: 'Review', tone: 'success' }, + { title: 'Publish', tone: 'warning' }, + { title: 'Share', tone: 'neutral' }, +] + +function createFlexPlayground() { + const selectedPreset = ref('distribute') + const layoutDirection = ref('row') + const itemAlignment = ref('center') + const contentDistribution = ref('between') + const wrapping = ref('wrap') + const tiers = names.map((name) => ({ + name, + label: `${name} / ${BREAKPOINTS[name].value} and up`, + direction: ref(''), + align: ref(''), + justify: ref(''), + wrap: ref(''), + })) + const gap = ref('3') + const height = ref('layout-playground-frame') + const width = ref('w-full') + const sizing = ref('flex-none') + const count = ref('3') + const varied = ref(false) + const grow = ref(false) + const inlineLayout = ref(false) + const element = ref('div') + const browserWidth = ref(window.innerWidth) + const measuredWidth = ref(0) + const overflowing = ref(false) + const copyLabel = ref('Copy markup') + const active = computed(() => { + const value = { + direction: layoutDirection(), + align: itemAlignment(), + justify: contentDistribution(), + wrap: wrapping(), + breakpoint: 'base', + } + for (const tier of tiers) { + if (browserWidth() < Number.parseInt(BREAKPOINTS[tier.name].value, 10)) + continue + value.breakpoint = tier.name + for (const key of ['direction', 'align', 'justify', 'wrap'] as const) { + if (tier[key]()) value[key] = tier[key]() + } + } + return value + }) + const axes = computed(() => + active().direction.startsWith('column') + ? 'Main axis: vertical / Cross axis: horizontal' + : 'Main axis: horizontal / Cross axis: vertical', + ) + const tiles = computed(() => + Array.from({ length: Number(count()) }, (_, index) => ({ + ...samples[index % samples.length], + index, + label: String(index + 1).padStart(2, '0'), + })), + ) + const layoutClass = computed(() => `gap-${gap()} p-3 m-0 min-w-0 list-none`) + const widthClass = computed(() => `${width()} max-w-full mx-auto`) + const frameClass = computed(() => `${height()} max-w-full`) + const itemClass = (index: number) => + `${grow() && index === 0 ? 'flex-1' : sizing()} min-w-0` + const titleClass = (index: number) => + `m-0 wrap-anywhere ${varied() ? ['fs-h6', 'fs-h5', 'fs-h4'][index % 3] : 'fs-h6'}` + const cardPadding = (index: number) => + varied() ? ['p-3', 'p-4', 'p-5'][index % 3] : 'p-3' + const markup = computed(() => { + const attrs = [ + `direction="${layoutDirection()}"`, + `align="${itemAlignment()}"`, + `justify="${contentDistribution()}"`, + `wrap="${wrapping()}"`, + ] + for (const tier of tiers) { + for (const key of ['direction', 'align', 'justify', 'wrap'] as const) { + if (tier[key]()) + attrs.push( + `${key}${tier.name[0].toUpperCase() + tier.name.slice(1)}="${tier[key]()}"`, + ) + } + } + if (inlineLayout()) attrs.push(':inline="true"') + if (element() !== 'div') attrs.push(`container="${element()}"`) + attrs.push(`class="${layoutClass()}"`) + const tag = element() === 'ul' ? 'li' : 'div' + return `\n${tiles() + .map( + (tile) => + ` \n \n \n

${tile.title}

\n ${tile.label}\n
\n
\n
`, + ) + .join('\n')}\n` + }) + const loadPreset = ( + preset: 'distribute' | 'responsive' | 'wrap' | 'baseline' | 'grow', + ) => + batch(() => { + selectedPreset(preset) + layoutDirection('row') + itemAlignment('center') + contentDistribution('between') + wrapping('wrap') + for (const tier of tiers) { + tier.direction('') + tier.align('') + tier.justify('') + tier.wrap('') + } + gap('3') + height('layout-playground-frame') + width('w-full') + sizing('flex-none') + count('3') + varied(false) + grow(false) + inlineLayout(false) + element('div') + if (preset === 'responsive') { + layoutDirection('column') + itemAlignment('stretch') + contentDistribution('start') + wrapping('nowrap') + tiers[1].direction('row') + tiers[1].align('center') + tiers[1].justify('between') + tiers[1].wrap('wrap') + sizing('flex-none') + height('layout-playground-frame layout-playground-frame--tall') + varied(false) + } + if (preset === 'wrap') { + width('layout-playground-width--narrow') + count('6') + contentDistribution('start') + varied(false) + } + if (preset === 'baseline') { + varied(true) + itemAlignment('baseline') + contentDistribution('start') + wrapping('nowrap') + } + if (preset === 'grow') { + grow(true) + sizing('flex-auto') + varied(false) + contentDistribution('start') + } + }) + let frame = 0 + let copyTimer: ReturnType | undefined + let resizeObserver: ResizeObserver | undefined + let mutationObserver: MutationObserver | undefined + const measure = () => { + frame = 0 + browserWidth(window.innerWidth) + const flex = document.getElementById('flex-lab-preview') + if (!flex) return + measuredWidth(Math.round(flex.getBoundingClientRect().width)) + overflowing( + flex.scrollWidth > flex.clientWidth + 1 || + flex.scrollHeight > flex.clientHeight + 1, + ) + } + const scheduleMeasure = () => { + if (!frame) frame = requestAnimationFrame(measure) + } + onMounted(() => { + const stage = document.getElementById('flex-lab-stage') + if (!stage) return + resizeObserver = new ResizeObserver(scheduleMeasure) + resizeObserver.observe(stage) + mutationObserver = new MutationObserver(scheduleMeasure) + mutationObserver.observe(stage, { + attributes: true, + childList: true, + subtree: true, + attributeFilter: ['style', 'class'], + }) + window.addEventListener('resize', scheduleMeasure) + scheduleMeasure() + }) + onUnmounted(() => { + resizeObserver?.disconnect() + mutationObserver?.disconnect() + window.removeEventListener('resize', scheduleMeasure) + cancelAnimationFrame(frame) + if (copyTimer) clearTimeout(copyTimer) + }) + return { + selectedPreset, + layoutDirection, + itemAlignment, + contentDistribution, + wrapping, + tiers, + gap, + height, + width, + sizing, + count, + varied, + grow, + inlineLayout, + element, + browserWidth, + measuredWidth, + overflowing, + active, + axes, + tiles, + layoutClass, + widthClass, + frameClass, + itemClass, + titleClass, + cardPadding, + markup, + copyLabel, + loadPreset, + sm: tiers[0], + md: tiers[1], + lg: tiers[2], + xl: tiers[3], + directionOptions, + alignOptions, + justifyOptions, + wrapOptions, + responsiveDirectionOptions: inherit(directionOptions), + responsiveAlignOptions: inherit(alignOptions), + responsiveJustifyOptions: inherit(justifyOptions), + responsiveWrapOptions: inherit(wrapOptions), + gapOptions: options(['0', '1', '2', '3', '4', '5', '6']).map((option) => ({ + ...option, + label: `gap-${option.value}`, + })), + heightOptions: [ + { + label: 'Compact', + value: 'layout-playground-frame layout-playground-frame--compact', + }, + { label: 'Regular', value: 'layout-playground-frame' }, + { + label: 'Tall', + value: 'layout-playground-frame layout-playground-frame--tall', + }, + ], + sizingOptions: options(['flex-none', 'flex-auto', 'flex-fill', 'flex-1']), + countOptions: [2, 3, 4, 6, 9].map((value) => ({ + label: `${value} items`, + value: String(value), + })), + widthOptions: [ + { label: 'Full available width', value: 'w-full' }, + { label: 'Narrow', value: 'layout-playground-width--narrow' }, + { label: 'Medium', value: 'layout-playground-width--medium' }, + { label: 'Wide', value: 'layout-playground-width--wide' }, + ], + containerOptions: options(['div', 'section', 'ul']), + reset: () => loadPreset('distribute'), + clearOverrides: () => + batch(() => { + for (const tier of tiers) { + tier.direction('') + tier.align('') + tier.justify('') + tier.wrap('') + } + }), + copy: async () => { + if (copyTimer) clearTimeout(copyTimer) + try { + await navigator.clipboard.writeText(markup()) + copyLabel('Copied') + } catch { + copyLabel('Select the markup below to copy') + } + copyTimer = setTimeout(() => copyLabel('Copy markup'), 2500) + }, + } +} + +const template = html` + +

Find your layout's rhythm

Flex layout lab

+ Live playground +
+ + Distribute items + Responsive stack + Wrapping cards + Shared baseline + Flexible first item + + + Live layout{{ measuredWidth }}px wide +
+
+
+ Before inline Flex + + + + +

{{ tile.title }}

+ {{ tile.label }} +
+
+
+
+ After inline Flex +
+
+
+
+

{{ axes }}
Browser: {{ browserWidth }}px / Breakpoint: {{ active.breakpoint }} / Active: {{ active.direction }} / {{ active.align }} / {{ active.justify }} / {{ active.wrap }}

+

Items exceed the preview bounds. Scroll the preview, choose flex-auto, enable wrapping, or increase its dimensions.

+
+ Direction & alignment + + + + + + + +
+
+ Responsive overrides / sm / md / lg / xl +

Overrides follow the browser viewport. Empty values inherit the smaller breakpoint independently for each property.

+
+ {{ tier.label }} + + + + + + +
+ Clear responsive overrides +
+
+ Space & item sizing + + + + + + + + + + + + +
+

Justify distributes space along the main axis; align positions items across it. Growing items consume available space before justify applies. In Flex, justify="stretch" behaves like start; use item growth to fill space. Reverse directions and reverse wrapping change visual placement while source order stays 01, 02, 03.

+ + Copyable Flex markup + Reset playground{{ copyLabel }} + +
{{ markup }}
+
` + +const component = defineComponent>( + template, + { context: createFlexPlayground }, +) +createApp( + { + components: { + FlexPlayground: component, + ...defineFlexComponents(), + ...defineGridComponents(), + ...definePanelComponents(), + ...defineBadgeComponents(), + ...defineButtonComponents(), + ...defineFormComponents(), + ...defineFormSelectField(), + ...defineIconComponents((name) => + name === 'lucide:chevron-down' ? lucide_chevron_down : '', + ), + }, + }, + { selector: 'app#flex-playground', template: html`` }, +) diff --git a/frontend/purestack.studio/components/layout/grid/grid.mdx b/frontend/purestack.studio/components/layout/grid/grid.mdx index 1aa76c50..9afe22d0 100644 --- a/frontend/purestack.studio/components/layout/grid/grid.mdx +++ b/frontend/purestack.studio/components/layout/grid/grid.mdx @@ -41,6 +41,28 @@ nav: ``` +## Interactive playground + +Start with a preset, then change the column counts, gap, item alignment, and packing. Choose **Auto-fit cards** and reduce the preview width to see cards wrap without a media query. Choose **Dense board**, then turn dense packing off to compare how later items fill earlier gaps. + +Responsive overrides use the browser viewport, not the preview width. Resize your browser to cross a breakpoint; the live readout shows which setting applies. Empty overrides inherit the smaller breakpoint. Spacing uses the framework's gap utilities. Wide cards use the same `layout-featured-card` class as the guide's spanning examples, starting at sm. + + + +
+ +
+
+ +```html + +``` + + + + +
+ ## A responsive card grid One column becomes two at sm and three at lg. Cards stretch to share the row height. diff --git a/frontend/purestack.studio/components/layout/grid/playground.ts b/frontend/purestack.studio/components/layout/grid/playground.ts new file mode 100644 index 00000000..741721a6 --- /dev/null +++ b/frontend/purestack.studio/components/layout/grid/playground.ts @@ -0,0 +1,422 @@ +import { + defineBadgeComponents, + defineButtonComponents, + defineFlexComponents, + defineFormComponents, + defineFormInputField, + defineFormSelectField, + defineGridComponents, + defineIconComponents, + definePanelComponents, + type FormSelectOption, +} from '@purestack/ts-components' +import { BREAKPOINTS } from '@purestack/ts-style' +import { lucide_chevron_down } from '@purestack/ts-svg-icons' +import { + batch, + computed, + createApp, + defineComponent, + html, + onMounted, + onUnmounted, + ref, +} from 'regor' + +const breakpoints = ['sm', 'md', 'lg', 'xl'] as const +type GridAlignment = 'stretch' | 'start' | 'center' | 'end' +const countOptions: FormSelectOption[] = Array.from({ length: 12 }, (_, i) => ({ + label: `${i + 1} column${i ? 's' : ''}`, + value: String(i + 1), +})) +const alignmentOptions: FormSelectOption[] = [ + 'stretch', + 'start', + 'center', + 'end', +].map((value) => ({ label: value, value })) +const samples = [ + { + title: 'Content', + description: 'Write a clear starting point.', + tone: 'accent', + }, + { + title: 'Components', + description: 'Compose a consistent interface.', + tone: 'info', + }, + { + title: 'Themes', + description: 'Make room for light and dark.', + tone: 'feature', + }, + { + title: 'Navigation', + description: 'Connect the next useful step.', + tone: 'success', + }, + { + title: 'Preview', + description: 'Review the details together.', + tone: 'warning', + }, + { + title: 'Publish', + description: 'Share what you have built.', + tone: 'neutral', + }, +] + +function escapeAttribute(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + +function createGridPlayground() { + const selectedPreset = ref('cards') + const base = ref('1') + const customTracks = ref('minmax(0, 2fr) minmax(0, 1fr)') + const sm = ref('2') + const md = ref('') + const lg = ref('3') + const xl = ref('') + const itemAlignment = ref('stretch') + const itemJustification = ref('stretch') + const dense = ref(false) + const wideItems = ref(false) + const variedHeights = ref(true) + const descriptions = ref(true) + const gap = ref('3') + const itemCount = ref('6') + const previewWidth = ref('w-full') + const element = ref('div') + const browserWidth = ref(window.innerWidth) + const measuredWidth = ref(0) + const measuredColumns = ref(0) + const copyLabel = ref('Copy markup') + const trackError = computed(() => + base() === 'custom' && + (!customTracks().trim() || + !CSS.supports('grid-template-columns', customTracks().trim())) + ? 'Enter a valid CSS grid-template-columns value. The preview uses one column until it is valid.' + : '', + ) + const columns = computed(() => + base() === 'custom' ? (trackError() ? '1' : customTracks().trim()) : base(), + ) + const responsive = [sm, md, lg, xl] + const activeBreakpoint = computed(() => { + const width = browserWidth() + return ( + [...breakpoints] + .reverse() + .find( + (name) => width >= Number.parseInt(BREAKPOINTS[name].value, 10), + ) ?? 'base' + ) + }) + const activeColumns = computed(() => { + let value = columns() + breakpoints.forEach((name, i) => { + if ( + browserWidth() >= Number.parseInt(BREAKPOINTS[name].value, 10) && + responsive[i]() + ) + value = responsive[i]() + }) + return value + }) + const spanFor = (value: string) => + /^\d+$/.test(value) && Number(value) >= 2 ? 2 : 1 + const canSpan = computed(() => + [columns(), ...responsive.map((value) => value()).filter(Boolean)].every( + (value) => spanFor(value) === 2, + ), + ) + const tiles = computed(() => + Array.from({ length: Number(itemCount()) }, (_, index) => ({ + ...samples[index % samples.length], + index, + label: String(index + 1).padStart(2, '0'), + })), + ) + const gridClass = computed(() => `gap-${gap()} p-0 m-0 list-none`) + const widthClass = computed(() => `${previewWidth()} max-w-full mx-auto`) + const itemClass = (index: number) => + `min-w-0${wideItems() && canSpan() && index < 2 ? ' layout-featured-card' : ''}` + const cardBodyClass = (index: number) => + variedHeights() ? ['p-3', 'p-5', 'p-4'][index % 3] : 'p-3' + const markup = computed(() => { + const attrs = [`columns="${escapeAttribute(columns())}"`] + breakpoints.forEach((name, i) => { + if (responsive[i]()) + attrs.push( + `columns${name[0].toUpperCase() + name.slice(1)}="${responsive[i]()}"`, + ) + }) + if (element() !== 'div') attrs.push(`container="${element()}"`) + attrs.push( + `alignItems="${itemAlignment()}"`, + `justifyItems="${itemJustification()}"`, + `:dense="${dense()}"`, + `class="${gridClass()}"`, + ) + const tag = element() === 'ul' ? 'li' : 'div' + const items = tiles() + .map( + (tile) => + ` \n \n \n ${tile.label}\n

${tile.title}

${descriptions() ? `\n

${tile.description}

` : ''}\n
\n
\n `, + ) + .join('\n') + return `\n${items}\n` + }) + const loadPreset = (preset: 'cards' | 'tracks' | 'auto' | 'dense') => + batch(() => { + selectedPreset(preset) + base('1') + sm('2') + md('') + lg('3') + xl('') + itemAlignment('stretch') + itemJustification('stretch') + dense(false) + wideItems(false) + variedHeights(true) + descriptions(true) + gap('3') + itemCount('6') + previewWidth('w-full') + element('div') + customTracks('minmax(0, 2fr) minmax(0, 1fr)') + if (preset === 'tracks' || preset === 'auto') { + base('custom') + sm('') + lg('') + if (preset === 'auto') + customTracks('repeat(auto-fit, minmax(min(100%, 180px), 1fr))') + } + if (preset === 'dense') { + base('3') + sm('') + lg('') + dense(true) + wideItems(true) + variedHeights(false) + itemCount('9') + } + }) + + let frame = 0 + let copyTimer: ReturnType | undefined + let resizeObserver: ResizeObserver | undefined + let mutationObserver: MutationObserver | undefined + const measure = () => { + frame = 0 + browserWidth(window.innerWidth) + const grid = document.getElementById('grid-lab-preview') + if (!grid) return + measuredWidth(Math.round(grid.getBoundingClientRect().width)) + measuredColumns( + getComputedStyle(grid).gridTemplateColumns.match(/\d+(?:\.\d+)?px/g) + ?.length ?? 0, + ) + } + const scheduleMeasure = () => { + if (!frame) frame = requestAnimationFrame(measure) + } + onMounted(() => { + const stage = document.getElementById('grid-lab-stage') + if (!stage) return + resizeObserver = new ResizeObserver(scheduleMeasure) + resizeObserver.observe(stage) + mutationObserver = new MutationObserver(scheduleMeasure) + mutationObserver.observe(stage, { + attributes: true, + childList: true, + subtree: true, + attributeFilter: ['style', 'class'], + }) + window.addEventListener('resize', scheduleMeasure) + scheduleMeasure() + }) + onUnmounted(() => { + resizeObserver?.disconnect() + mutationObserver?.disconnect() + window.removeEventListener('resize', scheduleMeasure) + cancelAnimationFrame(frame) + if (copyTimer) clearTimeout(copyTimer) + }) + return { + selectedPreset, + base, + customTracks, + sm, + md, + lg, + xl, + columns, + itemAlignment, + itemJustification, + dense, + wideItems, + variedHeights, + descriptions, + gap, + itemCount, + previewWidth, + element, + trackError, + canSpan, + tiles, + gridClass, + widthClass, + itemClass, + cardBodyClass, + markup, + browserWidth, + measuredWidth, + measuredColumns, + activeBreakpoint, + activeColumns, + copyLabel, + loadPreset, + reset: () => loadPreset('cards'), + baseOptions: [ + ...countOptions, + { label: 'Custom CSS tracks', value: 'custom' }, + ], + responsiveOptions: [ + { label: 'Inherit smaller breakpoint', value: '' }, + ...countOptions, + ], + alignmentOptions, + gapOptions: [0, 1, 2, 3, 4, 5, 6].map((value) => ({ + label: `gap-${value}`, + value: String(value), + })), + itemOptions: [3, 6, 9, 12].map((value) => ({ + label: `${value} items`, + value: String(value), + })), + widthOptions: [ + { label: 'Full available width', value: 'w-full' }, + { label: 'Narrow', value: 'layout-playground-width--narrow' }, + { label: 'Medium', value: 'layout-playground-width--medium' }, + { label: 'Wide', value: 'layout-playground-width--wide' }, + ], + containerOptions: ['div', 'section', 'ul'].map((value) => ({ + label: value, + value, + })), + copy: async () => { + if (copyTimer) clearTimeout(copyTimer) + try { + await navigator.clipboard.writeText(markup()) + copyLabel('Copied') + } catch { + copyLabel('Select the markup below to copy') + } + copyTimer = setTimeout(() => copyLabel('Copy markup'), 2500) + }, + } +} + +export type GridPlayground = ReturnType + +const template = html` + +

Make space for your content

Grid layout lab

+ Live playground +
+ + Responsive cards + Asymmetric tracks + Auto-fit cards + Dense board + + + + Live layout{{ measuredColumns }} tracks · {{ measuredWidth }}px available + +
+ + + + + {{ tile.label }} +

{{ tile.title }}

+

{{ tile.description }}

+
+
+
+
+
+
+

Browser: {{ browserWidth }}px · Active breakpoint: {{ activeBreakpoint }} · Columns: {{ activeColumns }}

+
+ Columns & breakpoints + + + + + + + + + + +
+
+ Spacing & alignment + + + + + + + + + + + + + +
+

Breakpoints follow the browser width. Auto-fit follows the available preview width. Wide cards use the guide's layout-featured-card class from sm onward and require numeric counts of two or more at every breakpoint; dense packing may change their visual order.

+ + Copyable Grid markup + Reset playground{{ copyLabel }} + + +
{{ markup }}
+
+
` + +const component = defineComponent(template, { + context: createGridPlayground, +}) +createApp( + { + components: { + GridPlayground: component, + ...defineGridComponents(), + ...defineFlexComponents(), + ...definePanelComponents(), + ...defineBadgeComponents(), + ...defineButtonComponents(), + ...defineFormComponents(), + ...defineFormInputField(), + ...defineFormSelectField(), + ...defineIconComponents((name) => + name === 'lucide:chevron-down' ? lucide_chevron_down : '', + ), + }, + }, + { selector: 'app#grid-playground', template: html`` }, +) diff --git a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx index 54e0d688..f61df418 100644 --- a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx +++ b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx @@ -182,6 +182,7 @@ For grid children, `justify-self-start`, `justify-self-center`, `justify-self-en | Class | Effect | | --- | --- | | `w-full`, `h-full` | `width: 100%` or `height: 100%`. | +| `max-w-full` | Caps width at `100%`; combine with a fixed width to fit a smaller container. | | `min-w-0` | Allows a flex or grid child to shrink below its content's preferred width. | | `max-h-inspector` | Limits height to `24rem`. | | `overflow-visible`, `overflow-auto`, `overflow-hidden` | Set overflow on both axes. | @@ -189,6 +190,8 @@ For grid children, `justify-self-start`, `justify-self-center`, `justify-self-en | `overflow-x-visible`, `overflow-y-visible` | Set overall overflow to visible; both axes are affected. | | `position-static`, `position-relative`, `position-absolute`, `position-fixed`, `position-sticky` | Set CSS positioning. | | `ws-normal`, `ws-nowrap`, `ws-pre-wrap` | Set `white-space`. | +| `wrap-anywhere` | Allows long text to break anywhere when needed to fit its container. | +| `list-none` | Removes list markers. Pair with `m-0 p-0` to also remove default list spacing. | | `cursor-pointer`, `col-resize` | Set pointer or column-resize cursor. | | `bg-none` | Remove a background. | diff --git a/packages/ts-style/src/utilities.ts b/packages/ts-style/src/utilities.ts index e73297ac..3fc37770 100644 --- a/packages/ts-style/src/utilities.ts +++ b/packages/ts-style/src/utilities.ts @@ -170,6 +170,8 @@ function applyDisplayUtilities(theme: string) { function applyLayoutUtilities(theme: string) { styleBuilder.select('.w-full', theme).width(force('100%')) styleBuilder.select('.h-full', theme).height(force('100%')) + styleBuilder.select('.max-w-full', theme).maxWidth(force('100%')) + styleBuilder.select('.list-none', theme).set('list-style', force('none')) styleBuilder.select('.max-h-inspector', theme).maxHeight(force('24rem')) styleBuilder.select('.auto-fit', theme).width(force('1%')) styleBuilder.select('.min-w-0', theme).minWidth(force('0')) @@ -546,6 +548,9 @@ function applyTextUtilities(theme: string, palette: ThemePalette) { styleBuilder.select('.text-end', theme).textAlign('end !important') styleBuilder.select('.text-justify', theme).textAlign('justify !important') styleBuilder.select('.text-ellipsis', theme).textOverflow('ellipsis') + styleBuilder + .select('.wrap-anywhere', theme) + .set('overflow-wrap', force('anywhere')) styleBuilder.select('.uppercase', theme).textTransform('uppercase !important') styleBuilder.select('.lowercase', theme).textTransform('lowercase !important') From 9a895dd10e26f4bb43f669047b3d65939bdb3349 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 6 Oct 2026 19:40:06 +0200 Subject: [PATCH 5/5] CI now checks embed and formattings. --- .github/workflows/ci.yml | 19 +++++++++++++++++++ package.json | 3 +-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8f9886c..a8b85554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,25 @@ jobs: - name: Check line endings run: yarn check-eol + - name: Regenerate Regor component metadata + run: yarn regor-components + + - name: Regenerate embedded scripts + run: yarn embed + + - name: Format repository + run: yarn format + + - name: Verify generated and formatted files are committed + shell: bash + run: | + if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then + echo "::error::Run yarn regor-components, yarn embed, and yarn format locally, then commit the resulting changes." + git status --short --untracked-files=all + git diff --stat + exit 1 + fi + - name: Lint without changing files run: yarn lint:ci diff --git a/package.json b/package.json index 6dc70700..45738098 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "lint": "yarn workspaces foreach --no-private --all --verbose --topological -- run lint", "lint:ci": "yarn workspaces foreach --no-private --all --verbose --topological -- exec biome check ./src --formatter-enabled=false", "check-eol": "yarn tsx scripts/check-eol.ts", - "sync-purestack-version": "yarn tsx --no-warnings scripts/sync-purestack-version.ts", "bump-version": "yarn tsx --no-warnings scripts/bump-version.ts", "bundle": "yarn tsx --no-warnings scripts/bundle.ts", "cleanup": "yarn tsx --no-warnings scripts/cleanup.ts", @@ -53,6 +52,6 @@ "pack-vscode-ext": "yarn --cwd packages/ts-ssg-vscode build && yarn --cwd packages/ts-ssg-vscode package", "longest": "yarn tsx ./scripts/longest-functions.ts", "format": "biome format --write", - "pre-commit": "yarn check-eol && yarn sync-purestack-version && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run" + "pre-commit": "yarn check-eol && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run" } }