diff --git a/.gitignore b/.gitignore index ade8815df7a4457dbcb2b221bea5eceb19411591..4ba7ffbd641590aa2f52a4178071a954763ec65f 100755 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ server.log .env mini-services/ + +# local screenshots, never published +.zscripts/ diff --git a/.vercelignore b/.vercelignore new file mode 100755 index 0000000000000000000000000000000000000000..5febd01d6ef9ed214be67aa349cebd0af386689a --- /dev/null +++ b/.vercelignore @@ -0,0 +1,16 @@ +/download/ +/upload/ +/tool-results/ +/scripts/ +/tests/ +/examples/ +/mini-services/ +/db/ +/prisma/ +/node_modules/ +/worklog.md +/*.tar.gz +/dev.log +/server.log +/.vercel +/package/ diff --git a/AGENTS.md b/AGENTS.md old mode 100644 new mode 100755 diff --git a/Caddyfile b/Caddyfile new file mode 100755 index 0000000000000000000000000000000000000000..e4fb2a1abdc3542f710cab68b4b9d342865238f1 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,23 @@ +:81 { + @transform_port_query { + query XTransformPort=* + } + + handle @transform_port_query { + reverse_proxy localhost:{query.XTransformPort} { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Real-IP {remote_host} + } + } + + handle { + reverse_proxy localhost:3000 { + header_up Host {host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Real-IP {remote_host} + } + } +} diff --git a/GUIDE.md b/GUIDE.md old mode 100644 new mode 100755 index 62edb977e60110d1af26b4fb24b2dfc24295144a..6aac3439290a04c1797e2b840574a8e02412732b --- a/GUIDE.md +++ b/GUIDE.md @@ -109,6 +109,44 @@ Token overrides can be scoped under `@media` or `@signal` for themes. .hero { center: both } ``` +Each place word owns an axis (v0.8.0 — the example above used to silently +lose the `between` because every word wrote both axes): + +- `between`, `around`, `evenly` are main-axis words (`justify-content`) +- `stretch`, `baseline` are cross-axis words (`align-items`) +- `center`, `start`, `end` fill whichever axes are still free + +One distribution word + one alignment word is the whole grammar. A word +whose axes are already taken warns loudly ("has nowhere to go") instead of +overwriting, and a word written twice warns too. + +### Sizing that moves: fluid + +`font-size: fluid 16 24` (and any length-ish property: width, height, gap, +padding, margin…) compiles to the linear `clamp()` between 360px and 1200px +viewports — the formula everyone writes wrong by hand. Custom bounds: +`font-size: fluid 16 24 400 800`. The comma form still works: `fluid: 16, 24`. + +### raw { } — the escape hatch + +DSSL has no sugar for `@font-face`, `@keyframes`, `@supports`, `@import` or +`@property` yet. `raw { }` (alias `@css { }`) drops verbatim CSS into the +page stylesheet, top-level only: + +```dssl +raw { + @font-face { + font-family: Inter + src: url(/fonts/inter.woff2) + } +} +``` + +Unknown at-rules outside `raw { }` are skipped whole, with one clean error +and a pointer here — they never leak their inner rules (that was a real +0.7.2 bug: `@supports (display:grid) { .a { … } }` applied `.a` everywhere +after erroring). + ### The cascade is (layer, order) No specificity, no `!important`. Later wins inside a layer. Layers sort @@ -211,7 +249,7 @@ for liveness. `unfreeze()` re-arms everything. Three ways to get it: -1. Download `dssl-vscode/dssl-vscode-0.7.2.vsix` from the repo files, then +1. Download `dssl-vscode/dssl-vscode-0.8.0.vsix` from the repo files, then Extensions panel, `...`, Install from VSIX. Works in VS Code and VSCodium. 2. Build it: `cd dssl-vscode && npx @vscode/vsce package --no-dependencies`. 3. Registry listing: staged for Open VSX (the registry VSCodium searches). @@ -222,10 +260,31 @@ did-you-mean included, line numbers accurate since the v0.7.1 parser fix), completions folded from your workspace token registry, hover docs, a TextMate grammar for `.dssl`. +### The exit lane: dssl check + dssl build + +v0.8.0 answers the honest limits below without giving up the language: + +```sh +node cli/dssl.mjs check styles.dssl --strict # CI: real compiler, exit 1 on errors, --json +node cli/dssl.mjs build styles.dssl -o styles.css +``` + +`build` compiles the static subset to a real stylesheet: tokens become +custom properties, states become `:hover`, `@media`/`@container` pass +through, layers become `@layer`, motions become `@keyframes`, `pin` becomes +`!important`, `raw { }` passes through verbatim, and `seen` applies its +final state unconditionally (the graceful no-JS look). What needs the +runtime stays out and gets counted: `@signal` rules and `animate:`. +The same output is available in the browser as `DSSL.toCSS(source)`. + ## 6. Tooling and tests ```sh -node scripts/test-v072.mjs # latest regression suite (40 assertions) +node cli/dssl.mjs check public/styles/*.dssl # compile everything, exit code for CI +node scripts/test-v073.mjs # 0.8.0 regression suite (65 assertions) +node scripts/test-guide-examples.mjs # every guide example compiles +node scripts/test-guide-browser.mjs # guide examples asserted in headless Chromium +node scripts/test-v072.mjs # 0.7.2 regression suite (40 assertions) node scripts/test-v071.mjs # parser line-number suite node scripts/test-v070.mjs # v0.7 feature suite (60) node scripts/test-v050.mjs # v0.5 feature suite (61) @@ -242,16 +301,24 @@ run the sync script so the editor extension ships the same compiler. Read this before choosing DSSL for a real site. -- **JavaScript is required.** No JS, no styles. The engine ships no CSS - fallback because there is no CSS. If a page must work without JS, use CSS. +- **JavaScript is required** for the live engine. No JS, no styles at runtime. + v0.8.0 adds the exit lane: `dssl build styles.dssl -o styles.css` compiles + the static subset to a real stylesheet you can `` instead of the + engine — tokens as custom properties, states as `:hover`, layers as + `@layer`, motions as `@keyframes`, `raw { }` verbatim. What genuinely needs + JS (`@signal`, `animate:`, deform, overlap) stays out and is counted in the + report, so you always know what a no-JS page gives up. If a page must work + without JS AND uses the dynamic features, use CSS. - **First paint.** Styles are applied by the engine after it loads, so large - pages can flash unstyled content (CLS). The docs site solves this by - inlining its stylesheets into the SSR HTML. The planned prerender mode - (server renders the final inline styles) is the real fix and is on the - PARITY roadmap as the next big item. + pages can flash unstyled content (CLS). The build output fixes this for + static pages (real CSS paints before any JS). For live pages, the docs site + solves it by inlining its stylesheets into the SSR HTML. The planned + prerender mode (server renders the final inline styles) is the real fix + and is on the PARITY roadmap as the next big item. - **CSP.** Pseudo-elements compile to one real stylesheet the engine injects; strict `style-src 'self'` blocks inline style attributes and that sheet. - DSSL wants `unsafe-inline` for styles today. + DSSL wants `unsafe-inline` for styles today — or use the `dssl build` + output, which is a normal external stylesheet and CSP-clean. - **Interop is invasive.** DSSL normal declarations beat external CSS, and `pin` beats external `!important`. Mixing with an existing CSS design system works but the engine wins by design. diff --git a/PARITY.md b/PARITY.md old mode 100644 new mode 100755 index c1b0449decf1d8bc6c94f7cddec60d45305700eb..cf7ff956bfb6be5a570f7f4122e8296a483bde13 --- a/PARITY.md +++ b/PARITY.md @@ -28,9 +28,9 @@ The point of this table is to stop two bad arguments: "DSSL is missing everythin | print styles (`@media print`) | not implemented | planned | | | `!important` | by design no | by-design-no | `pin { }` is the must-win block; specificity wars are the thing DSSL deletes | | Cascade | (layer, source order) | shipped | later wins; identical selectors resolve by order, deterministically | -| Static extraction / view-source styling | live engine only today; prerender snapshot planned | planned | freeze() already makes big static pages zero-cost after first paint | +| Static extraction / view-source styling | `dssl build` emits real CSS (v0.8.0); prerender snapshot for live pages planned | partially shipped | freeze() already makes big static pages zero-cost after first paint | | DevTools story | `DSSL.explain(el)` returns the cascade as data | shipped | matched rules with file/line, winning declaration per property, pseudo rules; browser-extension panel planned | -| Work without JavaScript | not possible for first paint (today) | planned (prerender) | the honest trade; content pages should wait for prerender or pick another tool | +| Work without JavaScript | `dssl build` emits the static subset as real CSS (v0.8.0); `@signal`/`animate:` stay runtime-only and counted | shipped for the static subset; prerender snapshot still planned for live pages | the honest trade has an exit now | ## The exit story @@ -39,6 +39,14 @@ Abandoning DSSL leaves you: a DOM of valid inline styles, one small ``. +- When booting is done the page fires a `dssl-ready` event. + +## 3. Write some DSSL + +`examples/hello.dssl` in this package: + +```dssl +body { + background: #f5f0e4 + color: #26201a + padding: 40 +} + +.card { + background: #fdfaf2 + border: 1 solid #ddd2bc + radius: 6 + padding: 24 + + hover { + box-shadow: 0 6 18 #26201a/16 + } +} +``` + +Bare numbers are pixels. States like `hover` are nested blocks. The engine keeps them working forever, even for elements added to the page later. + +## The JavaScript API + +`window.DSSL` is available after boot: + +- `DSSL.load(urls)` fetch and apply one or more `.dssl` files (fetched in parallel, applied in order) +- `DSSL.apply(source, { name, scope })` apply a source string +- `DSSL.compile(source)` parse and resolve without touching the DOM, returns `ok`, `errors` and stats +- `DSSL.signal(name, value)` drive styles from JS with `@signal` conditions in your `.dssl` +- `DSSL.refresh()` force a full recompute +- `DSSL.replay(selector)` replay entrance motions on matching elements +- `DSSL.errors` the last reported errors (also grouped in the console, with did-you-mean hints) +- `DSSL.version` engine version + +## Scoped engines + +```js +DSSL.apply(source, { scope: '#widget' }) +``` + +gives one subtree its own engine, its own tokens and its own cascade. The playground on the live site is built exactly this way. + +## The exit lane (v0.8.0) + +The engine is a JS runtime. When a page needs to work without JavaScript, sit +behind a strict CSP, or paint before any script, build the static subset to a +real stylesheet: + +```sh +node cli/dssl.mjs check styles.dssl --strict # CI: the real compiler, exit 1 on errors, --json for machines +node cli/dssl.mjs build styles.dssl -o styles.css +``` + +`build` emits real CSS: tokens become custom properties, states become +`:hover`, layers become `@layer`, motions become `@keyframes`, `pin` becomes +`!important`, `raw { }` passes through verbatim, and `seen` applies its final +state (the graceful no-JS look). The report counts what stays runtime-only +(`@signal` rules, `animate:`), so the boundary is explicit. + +In the browser the same output is `DSSL.toCSS(source)` → `{ css, report }`. + +Two more v0.8.0 tools worth knowing: `raw { }` (alias `@css { }`) drops +verbatim CSS like `@font-face` into the page stylesheet, and +`font-size: fluid 16 24` compiles to the linear `clamp()` between 360 and +1200px viewports. + +## Files in this package + +- `dssl/` the engine. `boot.js` is the entry, `index.js` the public API, `static.js` the build-target compiler. +- `cli/dssl.mjs` the CLI (`check` + `build`), no dependencies, Node 18+ +- `styles/tokens.dssl` the starter token set (colors, fonts, radii, shadows) +- `examples/hello.dssl` a tiny starter stylesheet +- `USAGE.md` this file +- `LICENSE` MIT + +Live site: https://dssl-sable.vercel.app +Source and full language docs: https://huggingface.co/EHS-Studio/dssl diff --git a/engine-package/hello.dssl b/engine-package/hello.dssl new file mode 100755 index 0000000000000000000000000000000000000000..a6e8a58936a57806fc2d688ffb868fcc9b05ad80 --- /dev/null +++ b/engine-package/hello.dssl @@ -0,0 +1,39 @@ +// hello.dssl — a tiny starter stylesheet +// bare numbers are px, hover is a nested state, /14 at the end of a color is alpha + +body { + background: #f5f0e4 + color: #26201a + padding: 40 + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif +} + +h1 { + color: #7c2d2d +} + +.card { + background: #fdfaf2 + border: 1 solid #ddd2bc + radius: 6 + padding: 24 + box-shadow: 0 2 6 #26201a/14 + transition: 200 + + hover { + box-shadow: 0 6 18 #26201a/16 + } +} + +button { + background: #7c2d2d + color: #f9efdd + padding: 10 18 + radius: 6 + border: 0 + transition: 200 + + hover { + background: #5e1f1f + } +} diff --git a/engine-package/install.sh b/engine-package/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..8a1df49227376920e414c909c36ed2d74cdb8c9a --- /dev/null +++ b/engine-package/install.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# DSSL engine installer +# curl -fsSL https://dssl-sable.vercel.app/install.sh | sh +set -e +BASE="https://dssl-sable.vercel.app" +ZIP="/tmp/dssl-engine-$$.zip" + +command -v curl >/dev/null 2>&1 || { echo "DSSL installer: 'curl' is required."; exit 1; } +command -v unzip >/dev/null 2>&1 || { echo "DSSL installer: 'unzip' is required."; exit 1; } + +echo "fetching the DSSL engine..." +curl -fsSL "$BASE/dssl-engine.zip" -o "$ZIP" +rm -rf dssl-engine +unzip -q "$ZIP" -d . +rm -f "$ZIP" + +echo "" +echo "DSSL engine installed to ./dssl-engine" +echo "read dssl-engine/USAGE.md to wire it into a page." diff --git a/examples/websocket/frontend.tsx b/examples/websocket/frontend.tsx new file mode 100755 index 0000000000000000000000000000000000000000..dcc2aff5fb6cdc44c371b66d3b86493d31b2ee79 --- /dev/null +++ b/examples/websocket/frontend.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { io } from 'socket.io-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +type User = { + id: string; + username: string; +} + +type Message = { + id: string; + username: string; + content: string; + timestamp: Date | string; + type: 'user' | 'system'; +} + +export default function SocketDemo() { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [username, setUsername] = useState(''); + const [isUsernameSet, setIsUsernameSet] = useState(false); + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [users, setUsers] = useState([]); + + useEffect(() => { + // Connect to websocket server + // Never use PORT in the URL, alyways use XTransformPort + // DO NOT change the path, it is used by Caddy to forward the request to the correct port + const socketInstance = io('/?XTransformPort=3003', { + transports: ['websocket', 'polling'], + forceNew: true, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 10000 + }) + + setSocket(socketInstance); + + socketInstance.on('connect', () => { + setIsConnected(true); + }); + + socketInstance.on('disconnect', () => { + setIsConnected(false); + }); + + socketInstance.on('message', (msg: Message) => { + setMessages(prev => [...prev, msg]); + }); + + socketInstance.on('user-joined', (data: { user: User; message: Message }) => { + setMessages(prev => [...prev, data.message]); + setUsers(prev => { + if (!prev.find(u => u.id === data.user.id)) { + return [...prev, data.user]; + } + return prev; + }); + }); + + socketInstance.on('user-left', (data: { user: User; message: Message }) => { + setMessages(prev => [...prev, data.message]); + setUsers(prev => prev.filter(u => u.id !== data.user.id)); + }); + + socketInstance.on('users-list', (data: { users: User[] }) => { + setUsers(data.users); + }); + + return () => { + socketInstance.disconnect(); + }; + }, []); + + const handleJoin = () => { + if (socket && username.trim() && isConnected) { + socket.emit('join', { username: username.trim() }); + setIsUsernameSet(true); + } + }; + + const sendMessage = () => { + if (socket && inputMessage.trim() && username.trim()) { + socket.emit('message', { + content: inputMessage.trim(), + username: username.trim() + }); + setInputMessage(''); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + sendMessage(); + } + }; + + return ( +
+ + + + WebSocket Demo + + {isConnected ? 'Connected' : 'Disconnected'} + + + + + {!isUsernameSet ? ( +
+ setUsername(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleJoin(); + } + }} + placeholder="Enter your username..." + disabled={!isConnected} + className="flex-1" + /> + +
+ ) : ( + <> + +
+ {messages.length === 0 ? ( +

No messages yet

+ ) : ( + messages.map((msg) => ( +
+
+
+

+ {msg.username} +

+

+ {msg.content} +

+
+ + {new Date(msg.timestamp).toLocaleTimeString()} + +
+
+ )) + )} +
+
+ +
+ setInputMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type a message..." + disabled={!isConnected} + className="flex-1" + /> + +
+ + )} +
+
+
+ ); +} diff --git a/examples/websocket/server.ts b/examples/websocket/server.ts new file mode 100755 index 0000000000000000000000000000000000000000..4db70dfb1e9ccca1a7f106ef4fadab7ed649e73a --- /dev/null +++ b/examples/websocket/server.ts @@ -0,0 +1,138 @@ +import { createServer } from 'http' +import { Server } from 'socket.io' + +const httpServer = createServer() +const io = new Server(httpServer, { + // DO NOT change the path, it is used by Caddy to forward the request to the correct port + path: '/', + cors: { + origin: "*", + methods: ["GET", "POST"] + }, + pingTimeout: 60000, + pingInterval: 25000, +}) + +interface User { + id: string + username: string +} + +interface Message { + id: string + username: string + content: string + timestamp: Date + type: 'user' | 'system' +} + +const users = new Map() + +const generateMessageId = () => Math.random().toString(36).substr(2, 9) + +const createSystemMessage = (content: string): Message => ({ + id: generateMessageId(), + username: 'System', + content, + timestamp: new Date(), + type: 'system' +}) + +const createUserMessage = (username: string, content: string): Message => ({ + id: generateMessageId(), + username, + content, + timestamp: new Date(), + type: 'user' +}) + +io.on('connection', (socket) => { + console.log(`User connected: ${socket.id}`) + + // Add test event handler + socket.on('test', (data) => { + console.log('Received test message:', data) + socket.emit('test-response', { + message: 'Server received test message', + data: data, + timestamp: new Date().toISOString() + }) + }) + + socket.on('join', (data: { username: string }) => { + const { username } = data + + // Create user object + const user: User = { + id: socket.id, + username + } + + // Add to user list + users.set(socket.id, user) + + // Send join message to all users + const joinMessage = createSystemMessage(`${username} joined the chat room`) + io.emit('user-joined', { user, message: joinMessage }) + + // Send current user list to new user + const usersList = Array.from(users.values()) + socket.emit('users-list', { users: usersList }) + + console.log(`${username} joined the chat room, current online users: ${users.size}`) + }) + + socket.on('message', (data: { content: string; username: string }) => { + const { content, username } = data + const user = users.get(socket.id) + + if (user && user.username === username) { + const message = createUserMessage(username, content) + io.emit('message', message) + console.log(`${username}: ${content}`) + } + }) + + socket.on('disconnect', () => { + const user = users.get(socket.id) + + if (user) { + // Remove from user list + users.delete(socket.id) + + // Send leave message to all users + const leaveMessage = createSystemMessage(`${user.username} left the chat room`) + io.emit('user-left', { user: { id: socket.id, username: user.username }, message: leaveMessage }) + + console.log(`${user.username} left the chat room, current online users: ${users.size}`) + } else { + console.log(`User disconnected: ${socket.id}`) + } + }) + + socket.on('error', (error) => { + console.error(`Socket error (${socket.id}):`, error) + }) +}) + +const PORT = 3003 +httpServer.listen(PORT, () => { + console.log(`WebSocket server running on port ${PORT}`) +}) + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('Received SIGTERM signal, shutting down server...') + httpServer.close(() => { + console.log('WebSocket server closed') + process.exit(0) + }) +}) + +process.on('SIGINT', () => { + console.log('Received SIGINT signal, shutting down server...') + httpServer.close(() => { + console.log('WebSocket server closed') + process.exit(0) + }) +}) \ No newline at end of file diff --git a/llms.txt b/llms.txt index 1e185cb44368ab7be89bc4c8d8c148cef355abaa..789eb21fe169cdd270a4174b238f5be131a724ef 100755 --- a/llms.txt +++ b/llms.txt @@ -1,28 +1,28 @@ # DSSL -> DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform (the engine ends in inline styles the browser parses). You write `.dssl` files, the page loads a ~20 kB engine, and the engine walks the DOM in real time applying every matched rule as inline styles. Nothing compiles to CSS. Elements added later are styled the moment they appear; signals, states and motion share one grammar. +> DSSL is a style language for the web. For JS-guaranteed pages it replaces writing CSS; it does not replace the CSS platform. You write `.dssl` files, the page loads a ~20 kB engine, and the engine walks the DOM in real time applying every matched rule as inline styles. Nothing compiles to CSS at runtime — but v0.8.0 adds the exit lane: `dssl build` compiles the static subset to a real .css file for no-JS, strict-CSP or size-critical pages. Elements added later are styled the moment they appear; signals, states and motion share one grammar. - Site (docs, playground, changelog, bench): https://dssl-sable.vercel.app - Repo + full docs in the README: https://huggingface.co/EHS-Studio/dssl - Install: `curl -fsSL https://dssl-sable.vercel.app/install.sh | sh` (or the zip: https://dssl-sable.vercel.app/dssl-engine.zip) -- Version: 0.7.2 (engine), MIT license +- Version: 0.8.0 (engine), MIT license - Feature matrix vs CSS: see PARITY.md in the repo - For agents working ON the codebase: see AGENTS.md in the repo ## The five things reviewers ask about 1. No build step. Styles are source files linked with `` or embedded in the page as ` + +
card
+
ab
+ +
1234
+ + +
hero
+${blocks.map(b => ``).join('\n')} + +`; + +let pass = 0, fail = 0; +const ok = (name, cond, extra) => { + if (cond) { pass++; console.log(` ok ${name}`); } + else { fail++; console.log(`FAIL ${name}${extra ? ' — ' + extra : ''}`); } +}; + +// served over the local server so module imports are same-origin +const harnessPath = join(ROOT, '.guide-harness.html'); +writeFileSync(harnessPath, html); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); +const consoleErrors = []; +page.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') consoleErrors.push(m.type() + ': ' + m.text()); }); +page.on('pageerror', e => consoleErrors.push('pageerror: ' + e.message)); + +await page.goto(`http://127.0.0.1:${PORT}/.guide-harness.html`, { waitUntil: 'domcontentloaded' }); +await page.waitForFunction(() => window.DSSL && window.__dsslBooted, { timeout: 8000 }).catch(() => {}); +await page.waitForTimeout(600); // IntersectionObserver + animations settle + +const cs = (sel, prop, pseudo) => page.evaluate(([sel, prop, pseudo]) => { + const el = document.querySelector(sel); + if (!el) return null; + return getComputedStyle(el, pseudo || null).getPropertyValue(prop).trim(); +}, [sel, prop, pseudo]); + +/* block 1 — tokens + card base. Note: the guide composes — block 4 adds + * @media 768 { padding: 24 }, which legitimately wins at 1200px wide. */ +ok('B1 card background is the token ink value', (await cs('.card', 'background-color')) === 'rgb(22, 22, 29)', await cs('.card', 'background-color')); +ok('B1 card padding at 1200px is block 4\'s @media 768 value (24px)', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding')); +ok('B1 card radius is radius.md (14px)', (await cs('.card', 'border-radius')) === '14px', await cs('.card', 'border-radius')); +ok('B1 card shadow resolves the md token (0 2 4 + alpha)', (await cs('.card', 'box-shadow')).includes('0px 2px 4px'), await cs('.card', 'box-shadow')); + +/* block 2 — the layout words (the between-center bug lives here) */ +ok('B2 toolbar justify-content space-between (the review bug)', (await cs('.toolbar', 'justify-content')) === 'space-between', await cs('.toolbar', 'justify-content')); +ok('B2 toolbar align-items center survives the same line', (await cs('.toolbar', 'align-items')) === 'center', await cs('.toolbar', 'align-items')); +ok('B2 toolbar is flex row with 8px gap', (await cs('.toolbar', 'display')) === 'flex' && (await cs('.toolbar', 'gap')) === '8px'); +ok('B2 sidebar is a column with 16px gap', (await cs('.sidebar', 'flex-direction')) === 'column' && (await cs('.sidebar', 'gap')) === '16px'); +const boardCols = await cs('.board', 'grid-template-columns'); +ok('B2 board is a grid (auto-fill 220 form wins as the later rule)', (await cs('.board', 'display')) === 'grid' && boardCols.split(' ').length >= 2, boardCols); +ok('B2 gallery grid: 2 cols, rows 3, gap 8', (await cs('.gallery', 'grid-template-columns')).split(' ').length === 2 && (await cs('.gallery', 'gap')) === '8px', `${await cs('.gallery', 'grid-template-columns')} / ${await cs('.gallery', 'gap')}`); +const modalAI = await cs('.modal', 'align-items'), modalJI = await cs('.modal', 'justify-items'); +ok('B2 modal stack centers both axes', modalAI === 'center' && modalJI === 'center', `${modalAI} / ${modalJI}`); +const heroPlace = await cs('.hero', 'place-items'); +ok('B2 hero center: both centers everything', (heroPlace === 'center center') || (await cs('.hero', 'display')) === 'grid', heroPlace); + +/* block 3 — hover beats base, by order (the 0.7.2 headline fix) */ +const before = await cs('.card', 'background-color'); +await page.hover('.card'); +await page.waitForTimeout(120); +const after = await cs('.card', 'background-color'); +ok('B3 hover changes the card background (real mouse)', before !== after && after === 'rgb(58, 47, 38)', `${before} -> ${after}`); +await page.mouse.move(0, 0); +await page.waitForTimeout(120); +ok('B3 un-hoving restores the base', (await cs('.card', 'background-color')) === before); + +/* block 4 — media conditions */ +await page.emulateMedia({ colorScheme: 'dark' }); +await page.waitForTimeout(150); +ok('B4 dark mode applies (#181310)', (await cs('.card', 'background-color')) === 'rgb(24, 19, 16)', await cs('.card', 'background-color')); +await page.setViewportSize({ width: 700, height: 900 }); +await page.waitForTimeout(150); +ok('B4 below 768 the wide padding is gone', (await cs('.card', 'padding')) === '16px', await cs('.card', 'padding')); +await page.setViewportSize({ width: 900, height: 900 }); +await page.waitForTimeout(150); +ok('B4 at 900px padding: 24 applies', (await cs('.card', 'padding')) === '24px', await cs('.card', 'padding')); +await page.emulateMedia({ colorScheme: 'light' }); +await page.setViewportSize({ width: 1200, height: 900 }); +await page.waitForTimeout(150); + +/* block 5 — seen + hover animations (WAAPI) */ +const animCount = await page.evaluate(() => { + const el = document.querySelector('.card'); + el.scrollIntoView(); + return new Promise(res => setTimeout(() => res(el.getAnimations().length), 400)); +}); +ok('B5 seen fires a real animation on the card', animCount > 0, 'animations: ' + animCount); +await page.hover('.card'); +await page.waitForTimeout(120); +const liftAnims = await page.evaluate(() => document.querySelector('.card').getAnimations().length); +ok('B5 hover plays the lift motion', liftAnims > 0, 'animations on hover: ' + liftAnims); + +ok('no console errors or warnings during the whole run', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | ')); + +await browser.close(); +server.close(); +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/scripts/test-guide-examples.mjs b/scripts/test-guide-examples.mjs old mode 100644 new mode 100755 diff --git a/scripts/test-v072.mjs b/scripts/test-v072.mjs old mode 100644 new mode 100755 index 220986a8b7ad0cb8d1a24e45cb50d52d4f300bd6..7201185bec61e1252c068babf503fc426e767e60 --- a/scripts/test-v072.mjs +++ b/scripts/test-v072.mjs @@ -131,7 +131,10 @@ const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r => } /* 12 — version */ { - ok(VERSION === '0.7.2', 'engine VERSION is 0.7.2'); + // v0.8.0 — pin loosened the same way v070's was: the suite must survive + // later releases, but catch an accidental DOWNGRADE below 0.7.2. + const [maj, min, pat] = String(VERSION).split('.').map(Number); + ok(maj === 0 && (min > 7 || (min === 7 && pat >= 2)), 'engine VERSION is >= 0.7.2 (got ' + VERSION + ')'); ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported'); } diff --git a/scripts/test-v073.mjs b/scripts/test-v073.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c6d7b931b24616dd63443b82694646c704734479 --- /dev/null +++ b/scripts/test-v073.mjs @@ -0,0 +1,217 @@ +/* test-v073.mjs — one assertion per fix in the 0.8.0 "the exit drop". + * Source: the 2026-10-23 Claude review (5 bugs) + wishlist items 1/3/4/6/8. + * Every claim was reproduced first (scripts/repro-v073.mjs), these assert the + * fixes and the new features against the real compiler. */ +import { parse } from '../public/dssl/parser.js'; +import { resolve } from '../public/dssl/resolver.js'; +import { toCSS } from '../public/dssl/static.js'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +let pass = 0, fail = 0; +const ok = (name, cond, extra) => { + if (cond) { pass++; console.log(` ok ${name}`); } + else { fail++; console.log(`FAIL ${name}${extra ? ' — ' + extra : ''}`); } +}; + +const compileDoc = (src, file = 't.dssl') => { + const { ast, errors } = parse(src, file); + const doc = resolve(ast, file); + return { doc, errors: [...errors, ...doc.errors] }; +}; +const declsOf = (doc, i = 0) => doc.rules[i]?.decls || []; +const count = (decls, prop) => decls.filter(d => d.prop === prop).length; +const val = (decls, prop) => decls.find(d => d.prop === prop)?.value; +const lastVal = (decls, prop) => decls.filter(d => d.prop === prop).pop()?.value; + +/* ---- bug 1: row/column axis roles ---- */ +{ + const { doc, errors } = compileDoc('toolbar { row: gap 8 between center }'); + const d = declsOf(doc); + ok('1a. between center: justify-content is space-between, written once', count(d, 'justify-content') === 1 && val(d, 'justify-content') === 'space-between'); + ok('1b. between center: align-items is center', val(d, 'align-items') === 'center'); + ok('1c. between center: zero errors', errors.length === 0); + const w = compileDoc('toolbar { row: gap 8 center between }'); + ok('1d. center between: warns about the justify-content override', w.doc.warnings.some(x => /overrides "center"/.test(x.message))); + ok('1e. center between: space-between still wins (later word, cascade takes last)', lastVal(declsOf(w.doc), 'justify-content') === 'space-between'); + const dupe = compileDoc('toolbar { row: center center }'); + ok('1f. center center: duplicate-word warning', dupe.doc.warnings.some(x => /written twice/.test(x.message))); + const nowhere = compileDoc('toolbar { row: start end }'); + ok('1g. start end: "end" has nowhere to go, loudly', nowhere.doc.warnings.some(x => /nowhere to go/.test(x.message))); + const stretch = compileDoc('toolbar { row: gap 8 stretch }'); + ok('1h. row: stretch sets align-items only (no justify-content filler)', count(declsOf(stretch.doc), 'align-items') === 1 && count(declsOf(stretch.doc), 'justify-content') === 0); + const stack = compileDoc('.wrap { stack: center }'); + ok('1i. stack: center unchanged (place-items center center)', val(declsOf(stack.doc), 'place-items') === 'center center'); + const col = compileDoc('.list { column: gap 2 evenly stretch }'); + const cd = declsOf(col.doc); + ok('1j. column evenly stretch: main space-evenly + cross stretch', val(cd, 'justify-content') === 'space-evenly' && val(cd, 'align-items') === 'stretch'); +} + +/* ---- bug 2: @font-face must not throw ---- */ +{ + let threw = null, doc = null, errors = []; + try { + const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }'); + doc = r.doc; errors = r.errors; + } catch (e) { threw = e; } + ok('2a. @font-face does not throw', !threw, threw && threw.message); + ok('2b. @font-face reports exactly one clean error', errors.length === 1 && /@font-face/.test(errors[0].message)); + ok('2c. @font-face inner decls leak nowhere', !doc.rules.some(r => r.decls?.some(d => d.prop === 'src' || d.prop === 'font-family'))); +} + +/* ---- bug 3: unknown at-rules skip their whole block ---- */ +{ + const { doc, errors } = compileDoc('@supports (display:grid) { .a { display: grid } }\n.b { color: blue }'); + ok('3a. @supports inner rule does NOT apply unconditionally', !doc.rules.some(r => r.sel === 'a' || r.sel === '.a')); + ok('3b. one clean error for @supports (no stray unexpected })', errors.length === 1 && /@supports/.test(errors[0].message)); + ok('3c. the rule after the block still parses', doc.rules.some(r => (r.sel || '').includes('b'))); + ok('3d. the hint points at raw { }', /raw \{ \}/.test(errors[0].hint || '')); + const unclosed = compileDoc('@font-face { font-family: X'); + ok('3e. unclosed unknown block says so instead of eating the file', unclosed.errors.some(e => /never closed/.test(e.message))); +} + +/* ---- bug 4: url() is atomic ---- */ +{ + const { doc, errors } = compileDoc('.a { background: url(a.png) no-repeat }'); + ok('4a. unquoted url() + no-repeat: zero errors', errors.length === 0); + ok('4b. value passes through untouched', val(declsOf(doc), 'background') === 'url(a.png) no-repeat'); + const q = compileDoc('.a { background: url("a.png") no-repeat center / cover }'); + ok('4c. quoted url + position/size also clean', q.errors.length === 0 && /url\("a\.png"\) no-repeat center \/ cover/.test(val(declsOf(q.doc), 'background'))); + const s = compileDoc('.a { background-image: url(img/hero.png), linear-gradient(90, red -> blue) }'); + ok('4d. unquoted url inside a list with a gradient: clean', s.errors.length === 0); +} + +/* ---- bug 5a: animation gets motion guidance, not "span" ---- */ +{ + const defined = compileDoc('motion spin { from { opacity: 0 } to { opacity: 1 } }\n.a { animation: spin 1s infinite }'); + ok('5a-1. animation with a defined motion: silent', defined.errors.length === 0 && defined.doc.warnings.length === 0); + const missing = compileDoc('.a { animation: ghost 1s infinite }'); + ok('5a-2. animation with an undefined motion: warning names the DSSL way', missing.doc.warnings.some(w => /motion/.test(w.message) && /animate/.test(w.hint || ''))); + ok('5a-3. no "span" suggestion anywhere', !JSON.stringify(missing.errors).includes('span')); + ok('5a-4. bare duration becomes ms, not px', /400ms/.test((() => { const r = compileDoc('.a { animation: ghost 400 }'); return val(declsOf(r.doc), 'animation'); })())); + const animateHint = compileDoc('.a { animate: spin 400 }'); + ok('5a-5. unknown motion hint teaches the motion block', /motion spin \{/.test(animateHint.errors[0].hint || '')); +} + +/* ---- bug 5b: !important is loud and NOT emitted ---- */ +{ + const { doc, errors } = compileDoc('.a { color: red !important }'); + ok('5b-1. !important errors', errors.length === 1 && /!important does not exist/.test(errors[0].message)); + ok('5b-2. the error names pin { } as the answer', /pin \{ \}/.test(errors[0].hint || '')); + ok('5b-3. nothing is emitted', declsOf(doc).length === 0); + const quoted = compileDoc('.a { content: "!important" }'); + ok('5b-4. quoted "!important" is just text, no false trip', quoted.errors.length === 0 && val(declsOf(quoted.doc), 'content') === '"!important"'); +} + +/* ---- bug 5c: CSS transition form teaches the DSSL form ---- */ +{ + const { errors } = compileDoc('.a { transition: color .2s ease }'); + ok('5c-1. CSS transition form errors (not silently dropped)', errors.length === 1); + ok('5c-2. the hint shows the exact conversion', /transition: 200 ease color/.test(errors[0].hint || '')); + const dssl = compileDoc('.a { transition: 200 ease color }'); + ok('5c-3. the DSSL form still compiles clean', dssl.errors.length === 0 && val(declsOf(dssl.doc), 'transition') === 'color 200ms ease'); +} + +/* ---- feature: raw { } / @css { } verbatim passthrough ---- */ +{ + const one = compileDoc('raw { @font-face { font-family: X; src: url(x.woff2) } }'); + ok('6a. raw { } captured verbatim', one.doc.raws.length === 1 && one.doc.raws[0].css.includes('@font-face') && one.doc.raws[0].css.includes('url(x.woff2)')); + const alias = compileDoc('@css { @keyframes x { from { opacity: 0 } } }'); + ok('6b. @css alias works', alias.doc.raws.length === 1 && alias.doc.raws[0].css.includes('@keyframes x')); + const multi = compileDoc('raw {\n @supports (display: grid) {\n .a { display: grid }\n }\n}\n.b { color: red }'); + ok('6c. multi-line raw captured whole, parsing resumes after', multi.doc.raws.length === 1 && multi.doc.raws[0].css.includes('@supports') && multi.doc.rules.some(r => (r.sel || '').includes('b'))); + const nested = compileDoc('.card { raw { a } }'); + ok('6d. nested raw is a loud error', nested.errors.some(e => /raw \{ \} must be top-level/.test(e.message))); + const unclosed = compileDoc('raw { @font-face {'); + ok('6e. unclosed raw is a loud error', unclosed.errors.some(e => /raw \{ \} block was never closed/.test(e.message))); +} + +/* ---- feature: fluid sugar ---- */ +{ + const inline = compileDoc('.a { font-size: fluid 16 24 }'); + ok('7a. font-size: fluid 16 24 → clamp', val(declsOf(inline.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)'); + const bounds = compileDoc('.a { font-size: fluid 16 24 400 800 }'); + ok('7b. custom viewport bounds change the slope', val(declsOf(bounds.doc), 'font-size') === 'clamp(16px, calc(16px + 2.000vw), 24px)'); + const gap = compileDoc('.a { gap: fluid 8 16 }'); + ok('7c. gap takes inline fluid too', val(declsOf(gap.doc), 'gap') === 'clamp(8px, calc(8px + 0.952vw), 16px)'); + const legacy = compileDoc('.a { fluid: 16, 24 }'); + ok('7d. the fluid: 16, 24 form still works', val(declsOf(legacy.doc), 'font-size') === 'clamp(16px, calc(16px + 0.952vw), 24px)'); +} + +/* ---- feature: toCSS — the exit lane ---- */ +{ + const src = `tokens { color { ink: #16161d } } +@layer utilities { .toolbar { row: gap 8 between center } } +.card { + background: color.card + hover { background: #f5f0e8 } + seen { opacity: 1 } + display: grid + animate: rise 400 + ::before { content: "→" } +} +pin { .legal { color: red } } +@media dark { .card { background: #111 } } +@signal theme == dark { .card { background: #000 } } +motion rise { from { opacity: 0 } to { opacity: 1 } } +raw { @font-face { font-family: X; src: url(x.woff2) } }`; + const { ast, errors } = parse(src, 'build.dssl'); + const doc = resolve(ast, 'build.dssl'); + const built = toCSS(doc, { file: 'build.dssl', banner: false }); + const css = built.css; + ok('8a. @layer statement declares the engine order', css.includes('@layer tokens, base, components, utilities, pin;')); + ok('8b. tokens become :root custom properties', css.includes('--dssl-color-ink: #16161d')); + ok('8c. nested hover becomes .card:hover', /\.card:hover\s*\{[^}]*#f5f0e8/.test(css)); + ok('8d. seen applies unconditionally in the build', /\.card\s*\{[^}]*opacity: 1/.test(css) && !/\.card:seen/.test(css)); + ok('8e. ::before is native CSS in the build', /\.card::before\s*\{[^}]*content: "→"/.test(css)); + ok('8f. pin becomes !important', /\.legal\s*\{[^}]*color: red !important/.test(css)); + ok('8g. @media dark wraps the rule', css.includes('@media (prefers-color-scheme: dark)')); + ok('8h. motion becomes @keyframes rise', css.includes('@keyframes rise')); + ok('8i. raw { } lands verbatim', css.includes('@font-face { font-family: X; src: url(x.woff2) }')); + ok('8i-2. animate-only decls are dropped, static ones kept', /\.card\s*\{[^}]*display: grid/.test(css) && !css.includes('@animate')); + ok('8j. @signal rules are skipped and counted', built.report.skippedSignals === 1 && !css.includes('theme == dark')); + ok('8k. report counts what shipped', built.report.rules >= 5 && built.report.keyframes === 1 && built.report.raws === 1 && built.report.seenStripped === 1); +} + +/* ---- feature: the CLI ---- */ +{ + const dir = mkdtempSync(join(tmpdir(), 'dssl-cli-')); + const cleanFile = join(dir, 'clean.dssl'); + const badFile = join(dir, 'bad.dssl'); + const warnFile = join(dir, 'warn.dssl'); + writeFileSync(cleanFile, '.a { color: red\n hover { color: blue } }\n'); + writeFileSync(badFile, '.a { color: red !important }\n'); + writeFileSync(warnFile, '.a { animation: ghost 400 }\n'); + const cli = join(import.meta.dirname, '..', 'cli', 'dssl.mjs'); + const run = (args) => { + try { return { out: execFileSync('node', [cli, ...args], { encoding: 'utf8' }), code: 0 }; } + catch (e) { return { out: (e.stdout || '') + (e.stderr || ''), code: e.status }; } + }; + const c0 = run(['check', cleanFile]); + ok('9a. check clean file exits 0', c0.code === 0); + const c1 = run(['check', badFile]); + ok('9b. check file with errors exits 1', c1.code === 1 && /!important/.test(c1.out)); + const cj = JSON.parse(run(['check', badFile, '--json']).out); + ok('9c. check --json is machine-readable', cj.files[0].ok === false && cj.files[0].errors[0].line === 1); + const cs = run(['check', warnFile, '--strict']); + ok('9d. check --strict makes warnings fatal', cs.code === 1); + const cw = run(['check', warnFile]); + ok('9e. check without --strict: warnings are not fatal', cw.code === 0); + const cssOut = join(dir, 'out.css'); + const b = run(['build', cleanFile, '-o', cssOut]); + ok('9f. build writes a .css file and reports', b.code === 0 && /\.css/.test(b.out)); + const built = readFileSync(cssOut, 'utf8'); + ok('9g. built CSS contains the hover rule', /\.a:hover\s*\{[^}]*blue/.test(built)); + const bj = JSON.parse(run(['build', cleanFile, '--json']).out); + ok('9h. build --json carries css + report', typeof bj.css === 'string' && bj.report.rules >= 1); + const missing = run(['check', join(dir, 'nope.dssl')]); + ok('9i. missing file exits 2', missing.code === 2); + const v = run(['--version']); + ok('9j. --version prints 0.8.0', v.out.trim() === '0.8.0'); + rmSync(dir, { recursive: true, force: true }); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/src/components/dssl/SiteRoot.tsx b/src/components/dssl/SiteRoot.tsx index c7238df6742a85e0ddb5f5293e6694aab2234295..146349d5b4ae37c8451e38c34c2b2f0667310c60 100755 --- a/src/components/dssl/SiteRoot.tsx +++ b/src/components/dssl/SiteRoot.tsx @@ -122,7 +122,7 @@ export default function SiteRoot() {
- { e.preventDefault(); go("changelog"); }}>v0.7.1 + { e.preventDefault(); go("changelog"); }}>v0.8.0