experimental web port of the Union style engine + adapters
styling theming css solidjs react union kde
0

Configure Feed

Select the types of activity you want to include in your feed.

react demo

+933 -34
+122 -1
package-lock.json
··· 13 13 "packages/solid-css", 14 14 "packages/solid-plugin", 15 15 "packages/solid-demo", 16 - "packages/react-plugin" 16 + "packages/react-plugin", 17 + "packages/react-demo" 17 18 ] 18 19 }, 19 20 "node_modules/@babel/code-frame": { ··· 218 219 "@babel/core": "^7.0.0-0" 219 220 } 220 221 }, 222 + "node_modules/@babel/plugin-transform-react-jsx-self": { 223 + "version": "7.29.7", 224 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", 225 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", 226 + "dev": true, 227 + "license": "MIT", 228 + "dependencies": { 229 + "@babel/helper-plugin-utils": "^7.29.7" 230 + }, 231 + "engines": { 232 + "node": ">=6.9.0" 233 + }, 234 + "peerDependencies": { 235 + "@babel/core": "^7.0.0-0" 236 + } 237 + }, 238 + "node_modules/@babel/plugin-transform-react-jsx-source": { 239 + "version": "7.29.7", 240 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", 241 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", 242 + "dev": true, 243 + "license": "MIT", 244 + "dependencies": { 245 + "@babel/helper-plugin-utils": "^7.29.7" 246 + }, 247 + "engines": { 248 + "node": ">=6.9.0" 249 + }, 250 + "peerDependencies": { 251 + "@babel/core": "^7.0.0-0" 252 + } 253 + }, 221 254 "node_modules/@babel/template": { 222 255 "version": "7.29.7", 223 256 "dev": true, ··· 317 350 }, 318 351 "node_modules/@nothing-but/utils": { 319 352 "version": "0.17.0", 353 + "dev": true, 354 + "license": "MIT" 355 + }, 356 + "node_modules/@rolldown/pluginutils": { 357 + "version": "1.0.0-beta.27", 358 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", 359 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", 320 360 "dev": true, 321 361 "license": "MIT" 322 362 }, ··· 560 600 "csstype": "^3.2.2" 561 601 } 562 602 }, 603 + "node_modules/@types/react-dom": { 604 + "version": "19.2.3", 605 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", 606 + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", 607 + "dev": true, 608 + "license": "MIT", 609 + "peerDependencies": { 610 + "@types/react": "^19.2.0" 611 + } 612 + }, 563 613 "node_modules/@union-web/core": { 564 614 "resolved": "packages/core", 565 615 "link": true 566 616 }, 617 + "node_modules/@union-web/react-demo": { 618 + "resolved": "packages/react-demo", 619 + "link": true 620 + }, 567 621 "node_modules/@union-web/react-plugin": { 568 622 "resolved": "packages/react-plugin", 569 623 "link": true ··· 580 634 "resolved": "packages/solid-plugin", 581 635 "link": true 582 636 }, 637 + "node_modules/@vitejs/plugin-react": { 638 + "version": "4.7.0", 639 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", 640 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", 641 + "dev": true, 642 + "license": "MIT", 643 + "dependencies": { 644 + "@babel/core": "^7.28.0", 645 + "@babel/plugin-transform-react-jsx-self": "^7.27.1", 646 + "@babel/plugin-transform-react-jsx-source": "^7.27.1", 647 + "@rolldown/pluginutils": "1.0.0-beta.27", 648 + "@types/babel__core": "^7.20.5", 649 + "react-refresh": "^0.17.0" 650 + }, 651 + "engines": { 652 + "node": "^14.18.0 || >=16.0.0" 653 + }, 654 + "peerDependencies": { 655 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" 656 + } 657 + }, 583 658 "node_modules/babel-plugin-jsx-dom-expressions": { 584 659 "version": "0.40.7", 585 660 "dev": true, ··· 981 1056 "node": ">=0.10.0" 982 1057 } 983 1058 }, 1059 + "node_modules/react-dom": { 1060 + "version": "19.2.7", 1061 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", 1062 + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", 1063 + "license": "MIT", 1064 + "dependencies": { 1065 + "scheduler": "^0.27.0" 1066 + }, 1067 + "peerDependencies": { 1068 + "react": "^19.2.7" 1069 + } 1070 + }, 1071 + "node_modules/react-refresh": { 1072 + "version": "0.17.0", 1073 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", 1074 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", 1075 + "dev": true, 1076 + "license": "MIT", 1077 + "engines": { 1078 + "node": ">=0.10.0" 1079 + } 1080 + }, 984 1081 "node_modules/rollup": { 985 1082 "version": "4.61.1", 986 1083 "dev": true, ··· 1023 1120 "@rollup/rollup-win32-x64-msvc": "4.61.1", 1024 1121 "fsevents": "~2.3.2" 1025 1122 } 1123 + }, 1124 + "node_modules/scheduler": { 1125 + "version": "0.27.0", 1126 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 1127 + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", 1128 + "license": "MIT" 1026 1129 }, 1027 1130 "node_modules/semver": { 1028 1131 "version": "6.3.1", ··· 1294 1397 "typescript": "^5.5.0" 1295 1398 } 1296 1399 }, 1400 + "packages/react-demo": { 1401 + "name": "@union-web/react-demo", 1402 + "version": "0.1.0", 1403 + "dependencies": { 1404 + "@union-web/core": "*", 1405 + "@union-web/react-plugin": "*", 1406 + "react": "^19.0.0", 1407 + "react-dom": "^19.0.0" 1408 + }, 1409 + "devDependencies": { 1410 + "@types/react": "^19.0.0", 1411 + "@types/react-dom": "^19.0.0", 1412 + "@vitejs/plugin-react": "^4.5.0", 1413 + "typescript": "^5.5.0", 1414 + "vite": "^6.0.0" 1415 + } 1416 + }, 1297 1417 "packages/react-plugin": { 1298 1418 "name": "@union-web/react-plugin", 1299 1419 "version": "0.1.0", ··· 1302 1422 "react": "^19.0.0" 1303 1423 }, 1304 1424 "devDependencies": { 1425 + "@types/node": "^22.0.0", 1305 1426 "@types/react": "^19.0.0", 1306 1427 "typescript": "^5.5.0", 1307 1428 "vite": "^6.0.0"
+7 -5
package.json
··· 8 8 "packages/solid-css", 9 9 "packages/solid-plugin", 10 10 "packages/solid-demo", 11 - "packages/react-plugin" 11 + "packages/react-plugin", 12 + "packages/react-demo" 12 13 ], 13 14 "scripts": { 14 15 "build": "tsc -b", 15 - "dev": "npm run -w @union-web/solid-demo dev", 16 - "build:demo": "npm run -w @union-web/solid-demo build", 17 - "preview": "npm run -w @union-web/solid-demo preview", 18 - "start": "node packages/core/dist/cli.js" 16 + "dev": "npm run -w @union-web/react-demo dev", 17 + "build:demo": "npm run -w @union-web/react-demo build", 18 + "preview": "npm run -w @union-web/react-demo preview", 19 + "start": "node packages/core/dist/cli.js", 20 + "dev:solid": "npm run -w @union-web/solid-demo dev" 19 21 } 20 22 }
+6 -4
packages/core/src/css/property-group.ts
··· 452 452 /* Border/outline shorthand */ 453 453 case 'border': { 454 454 const parts = raw.split(/\s+/); 455 - if (parts.length === 3) { 455 + if (parts.length >= 3) { 456 456 const w = parseLength(parts[0]); 457 457 if (!target['borderWidth'] && w && !isNaN(w.value)) target['borderWidth'] = w.value; 458 458 if (!target['borderStyle']) target['borderStyle'] = parts[1]; 459 - if (!target['borderColor']) target['borderColor'] = parts[2]; 459 + if (!target['borderColor']) target['borderColor'] = parts.slice(2).join(' '); 460 + } else if (raw === 'none') { 461 + target['borderWidth'] = 0; 460 462 } 461 463 break; 462 464 } ··· 491 493 case 'bottom': { 492 494 if (group === 'border') { 493 495 const parts = raw.split(/\s+/); 494 - if (parts.length === 3) { 496 + if (parts.length >= 3) { 495 497 const w = parseLength(parts[0]); 496 498 target[`${key}Width`] = w && !isNaN(w.value) ? w.value : undefined; 497 499 target[`${key}Style`] = parts[1]; 498 - target[`${key}Color`] = parts[2]; 500 + target[`${key}Color`] = parts.slice(2).join(' '); 499 501 } 500 502 } else if (group === 'outline') { 501 503 const parts = raw.split(/\s+/);
+1 -1
packages/core/src/css/selector.ts
··· 73 73 break; 74 74 } 75 75 case 'ClassSelector': { 76 - parts.push({ type: 'id', name: child.name }); 76 + parts.push({ type: 'hint', name: child.name }); 77 77 break; 78 78 } 79 79 case 'IdSelector': {
+12
packages/react-demo/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Union CSS Demo - React</title> 7 + </head> 8 + <body> 9 + <div id="root"></div> 10 + <script type="module" src="/src/main.tsx"></script> 11 + </body> 12 + </html>
+24
packages/react-demo/package.json
··· 1 + { 2 + "name": "@union-web/react-demo", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "vite build", 9 + "preview": "vite preview" 10 + }, 11 + "dependencies": { 12 + "@union-web/core": "*", 13 + "@union-web/react-plugin": "*", 14 + "react": "^19.0.0", 15 + "react-dom": "^19.0.0" 16 + }, 17 + "devDependencies": { 18 + "@types/react": "^19.0.0", 19 + "@types/react-dom": "^19.0.0", 20 + "@vitejs/plugin-react": "^4.5.0", 21 + "typescript": "^5.5.0", 22 + "vite": "^6.0.0" 23 + } 24 + }
+35
packages/react-demo/src/App.css
··· 1 + /** 2 + * App CSS should never be used for most parity issues! 3 + * This file should only be used for very general and broad browser <-> QML differences 4 + * like the lack of flexboxes. Most parity issues should be fixed by going through the Union 5 + * IR output and tweaking it there, or the output plugin integration of the Union IR output 6 + */ 7 + 8 + *, *::before, *::after { 9 + box-sizing: border-box; 10 + } 11 + 12 + body { 13 + margin: 0; 14 + padding: 40px; 15 + font-family: "Noto Sans", "Segoe UI", system-ui, sans-serif; 16 + background: #d6d8d9; 17 + color: #232629; 18 + display: flex; 19 + justify-content: center; 20 + } 21 + 22 + /* Page fills remaining space in the window */ 23 + [data-ctrl="page"] { 24 + flex: 1; 25 + display: flex; 26 + flex-direction: column; 27 + } 28 + 29 + a { 30 + color: var(--customcolor-kcolorscheme-active-view-foreground-link); 31 + text-decoration: underline; 32 + } 33 + a:visited { 34 + color: var(--customcolor-kcolorscheme-active-view-foreground-visited); 35 + }
+72
packages/react-demo/src/App.tsx
··· 1 + import { useState } from "react"; 2 + import { ApplicationWindow, ApplicationHeader, Page, ColumnLayout, RowLayout, TextArea, Text, Button } from "./lib/index.js"; 3 + import "./App.css"; 4 + 5 + function mdToHtml(src: string): string { 6 + return src 7 + .split(/\n{2,}/) 8 + .map(block => { 9 + const trimmed = block.trim(); 10 + if (!trimmed) return ""; 11 + const escaped = trimmed 12 + .replace(/&/g, "&amp;") 13 + .replace(/</g, "&lt;") 14 + .replace(/>/g, "&gt;"); 15 + const formatted = escaped 16 + .replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>") 17 + .replace(/`([^`]+)`/g, "<code>$1</code>") 18 + .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>") 19 + .replace(/\*([^*]+)\*/g, "<i>$1</i>") 20 + .replace(/^### (.+)$/gm, "<h3>$1</h3>") 21 + .replace(/^## (.+)$/gm, "<h2>$1</h2>") 22 + .replace(/^# (.+)$/gm, "<h1>$1</h1>") 23 + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') 24 + .replace(/\n/g, "<br>") 25 + .replace(/<\/(h[123])><br>/g, "</$1>"); 26 + return formatted.startsWith("<h") || formatted.startsWith("<pre") ? formatted : `<p>${formatted}</p>`; 27 + }) 28 + .join("\n"); 29 + } 30 + 31 + export function App() { 32 + const [source, setSource] = useState(""); 33 + const [output, setOutput] = useState(""); 34 + 35 + const handleFormat = () => { 36 + const html = mdToHtml(source); 37 + setOutput(html); 38 + }; 39 + 40 + const handleClear = () => { 41 + setSource(""); 42 + setOutput(""); 43 + }; 44 + 45 + return ( 46 + <ApplicationWindow> 47 + <ApplicationHeader title="Markdown Viewer React" /> 48 + 49 + <Page> 50 + <ColumnLayout style={{ padding: "18px", gap: "6px" }}> 51 + <TextArea 52 + value={source} 53 + onChange={setSource} 54 + debug 55 + placeholder="Write some Markdown code here" 56 + /> 57 + 58 + <RowLayout style={{ gap: "6px" }}> 59 + <Button onClick={handleFormat} debug>Format</Button> 60 + <Button onClick={handleClear}>Clear</Button> 61 + </RowLayout> 62 + 63 + <Text 64 + richText 65 + wrap 66 + text={output || "<p style='color:#898b8c'>Formatted output will appear here</p>"} 67 + /> 68 + </ColumnLayout> 69 + </Page> 70 + </ApplicationWindow> 71 + ); 72 + }
+33
packages/react-demo/src/lib/components/ApplicationHeader.tsx
··· 1 + import type { ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + title?: string; 8 + }; 9 + 10 + export function ApplicationHeader({ title, children }: Props) { 11 + const { current, UnionProvider } = useUnion({ 12 + role: 'applicationheader', 13 + context: null, 14 + hints: ['with-separator'], 15 + }); 16 + const style = styleGroupToReact(current); 17 + 18 + return ( 19 + <UnionProvider> 20 + <div 21 + data-ctrl="applicationheader" 22 + data-hints="with-separator" 23 + style={{ 24 + fontSize: '18px', 25 + minHeight: '48px', 26 + ...style, 27 + }} 28 + > 29 + {title ?? children} 30 + </div> 31 + </UnionProvider> 32 + ); 33 + }
+30
packages/react-demo/src/lib/components/ApplicationWindow.tsx
··· 1 + import type { ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + }; 8 + 9 + export function ApplicationWindow({ children }: Props) { 10 + const { current, UnionProvider } = useUnion({ role: 'applicationwindow', context: null }); 11 + const style = styleGroupToReact(current); 12 + 13 + return ( 14 + <UnionProvider> 15 + <div 16 + data-ctrl="applicationwindow" 17 + style={{ 18 + display: 'flex', 19 + flexDirection: 'column', 20 + width: '900px', 21 + minHeight: '600px', 22 + overflow: 'hidden', 23 + ...style, 24 + }} 25 + > 26 + {children} 27 + </div> 28 + </UnionProvider> 29 + ); 30 + }
+45
packages/react-demo/src/lib/components/Button.tsx
··· 1 + import { useState, type ReactNode, type MouseEvent } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + disabled?: boolean; 8 + debug?: boolean; 9 + onClick?: (e: MouseEvent) => void; 10 + }; 11 + 12 + export function Button({ children, disabled, onClick, debug }: Props) { 13 + const [hovered, setHovered] = useState(false); 14 + const [pressed, setPressed] = useState(false); 15 + 16 + const states: string[] = []; 17 + if (hovered) states.push('hovered'); 18 + if (pressed) states.push('pressed'); 19 + 20 + const { current, UnionProvider } = useUnion({ role: 'button', state: states, context: null, debug }); 21 + const style = styleGroupToReact(current); 22 + 23 + return ( 24 + <UnionProvider> 25 + <div 26 + data-ctrl="button" 27 + data-state={states.length > 0 ? states.join(' ') : undefined} 28 + style={{ 29 + display: 'flex', 30 + alignItems: 'center', 31 + justifyContent: 'center', 32 + userSelect: 'none', 33 + ...style, 34 + }} 35 + onPointerEnter={() => setHovered(true)} 36 + onPointerLeave={() => { setHovered(false); setPressed(false); }} 37 + onPointerDown={() => setPressed(true)} 38 + onPointerUp={() => setPressed(false)} 39 + onClick={disabled ? undefined : onClick} 40 + > 41 + {children} 42 + </div> 43 + </UnionProvider> 44 + ); 45 + }
+28
packages/react-demo/src/lib/components/ColumnLayout.tsx
··· 1 + import type { CSSProperties, ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + style?: CSSProperties; 8 + }; 9 + 10 + export function ColumnLayout({ children, style }: Props) { 11 + const { current, UnionProvider } = useUnion({ role: 'columnlayout', context: null }); 12 + const s = styleGroupToReact(current); 13 + 14 + return ( 15 + <UnionProvider> 16 + <div 17 + style={{ 18 + display: 'flex', 19 + flexDirection: 'column', 20 + ...s, 21 + ...style, 22 + }} 23 + > 24 + {children} 25 + </div> 26 + </UnionProvider> 27 + ); 28 + }
+20
packages/react-demo/src/lib/components/Page.tsx
··· 1 + import type { ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + }; 8 + 9 + export function Page({ children }: Props) { 10 + const { current, UnionProvider } = useUnion({ role: 'page', context: null }); 11 + const style = styleGroupToReact(current); 12 + 13 + return ( 14 + <UnionProvider> 15 + <div data-ctrl="page" style={style}> 16 + {children} 17 + </div> 18 + </UnionProvider> 19 + ); 20 + }
+28
packages/react-demo/src/lib/components/RowLayout.tsx
··· 1 + import type { CSSProperties, ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + style?: CSSProperties; 8 + }; 9 + 10 + export function RowLayout({ children, style }: Props) { 11 + const { current, UnionProvider } = useUnion({ role: 'rowlayout', context: null }); 12 + const s = styleGroupToReact(current); 13 + 14 + return ( 15 + <UnionProvider> 16 + <div 17 + style={{ 18 + display: 'flex', 19 + flexDirection: 'row', 20 + ...s, 21 + ...style, 22 + }} 23 + > 24 + {children} 25 + </div> 26 + </UnionProvider> 27 + ); 28 + }
+44
packages/react-demo/src/lib/components/Text.tsx
··· 1 + import type { CSSProperties, ReactNode } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + children?: ReactNode; 7 + text?: string; 8 + richText?: boolean; 9 + wrap?: boolean; 10 + debug?: boolean; 11 + style?: CSSProperties; 12 + }; 13 + 14 + export function Text({ children, text, richText, wrap, debug, style }: Props) { 15 + const { current } = useUnion({ role: 'text', debug }); 16 + const s = styleGroupToReact(current); 17 + 18 + if (richText) { 19 + return ( 20 + <div 21 + dangerouslySetInnerHTML={{ __html: text ?? '' }} 22 + style={{ 23 + whiteSpace: wrap === false ? 'nowrap' : 'pre-wrap', 24 + wordBreak: 'break-word', 25 + ...s, 26 + ...style, 27 + }} 28 + /> 29 + ); 30 + } 31 + 32 + return ( 33 + <div 34 + style={{ 35 + whiteSpace: wrap === false ? 'nowrap' : 'pre-wrap', 36 + wordBreak: 'break-word', 37 + ...s, 38 + ...style, 39 + }} 40 + > 41 + {text ?? children} 42 + </div> 43 + ); 44 + }
+58
packages/react-demo/src/lib/components/TextArea.tsx
··· 1 + import { useState, type CSSProperties } from 'react'; 2 + import { useUnion } from '@union-web/react-plugin'; 3 + import { styleGroupToReact } from '../utils.js'; 4 + 5 + type Props = { 6 + value?: string; 7 + placeholder?: string; 8 + disabled?: boolean; 9 + debug?: boolean; 10 + onInput?: (value: string) => void; 11 + onChange?: (value: string) => void; 12 + style?: CSSProperties; 13 + }; 14 + 15 + export function TextArea({ value, placeholder, disabled, debug, onInput, onChange, style }: Props) { 16 + const [hovered, setHovered] = useState(false); 17 + const [focused, setFocused] = useState(false); 18 + 19 + const states: string[] = []; 20 + if (hovered) states.push('hovered'); 21 + if (focused) states.push('visual-focus'); 22 + 23 + const { current } = useUnion({ role: 'textarea', state: states, debug }); 24 + const s = styleGroupToReact(current); 25 + 26 + const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { 27 + const target = e.target; 28 + target.style.height = 'auto'; 29 + target.style.height = `${target.scrollHeight}px`; 30 + const val = target.value; 31 + onChange?.(val); 32 + onInput?.(val); 33 + }; 34 + 35 + return ( 36 + <textarea 37 + data-ctrl="textarea" 38 + data-state={states.length > 0 ? states.join(' ') : undefined} 39 + value={value ?? ''} 40 + placeholder={placeholder} 41 + disabled={disabled} 42 + onPointerEnter={() => setHovered(true)} 43 + onPointerLeave={() => setHovered(false)} 44 + onFocus={() => setFocused(true)} 45 + onBlur={() => setFocused(false)} 46 + onChange={handleChange} 47 + style={{ 48 + display: 'block', 49 + fontFamily: 'inherit', 50 + fontSize: 'inherit', 51 + minHeight: '140px', 52 + resize: 'vertical', 53 + ...s, 54 + ...style, 55 + }} 56 + /> 57 + ); 58 + }
+8
packages/react-demo/src/lib/index.ts
··· 1 + export { ApplicationHeader } from './components/ApplicationHeader.js'; 2 + export { ApplicationWindow } from './components/ApplicationWindow.js'; 3 + export { Button } from './components/Button.js'; 4 + export { ColumnLayout } from './components/ColumnLayout.js'; 5 + export { Page } from './components/Page.js'; 6 + export { RowLayout } from './components/RowLayout.js'; 7 + export { Text } from './components/Text.js'; 8 + export { TextArea } from './components/TextArea.js';
+25
packages/react-demo/src/lib/utils.ts
··· 1 + import { styleGroupToCss } from '@union-web/core'; 2 + import type { StylePropertyGroup } from '@union-web/core'; 3 + import type { CSSProperties } from 'react'; 4 + 5 + const VENDOR_PREFIX_RE = /^(-webkit-|-moz-|-ms-|-o-)/; 6 + 7 + function cssPropToReact(prop: string): string { 8 + const match = prop.match(VENDOR_PREFIX_RE); 9 + if (match) { 10 + const prefix = match[1]; 11 + const rest = prop.slice(prefix.length); 12 + const camel = rest.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); 13 + return prefix[1].toUpperCase() + prefix.slice(2) + camel; 14 + } 15 + return prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); 16 + } 17 + 18 + export function styleGroupToReact(sgp: StylePropertyGroup): CSSProperties { 19 + const decls = styleGroupToCss(sgp, { useMinSize: true }); 20 + const style: Record<string, string> = {}; 21 + for (const { property, value } of decls) { 22 + style[cssPropToReact(property)] = value; 23 + } 24 + return style as CSSProperties; 25 + }
+13
packages/react-demo/src/main.tsx
··· 1 + import { createRoot } from "react-dom/client"; 2 + import { UnionIRProvider } from "@union-web/react-plugin"; 3 + import { rules } from "virtual:union-ir"; 4 + import { App } from "./App"; 5 + 6 + const root = document.getElementById("root"); 7 + if (root) { 8 + createRoot(root).render( 9 + <UnionIRProvider rules={rules}> 10 + <App /> 11 + </UnionIRProvider>, 12 + ); 13 + }
+7
packages/react-demo/src/vite-env.d.ts
··· 1 + /// <reference types="vite/client" /> 2 + 3 + declare module 'virtual:union-ir' { 4 + import type { StyleRule } from '@union-web/core'; 5 + export const rules: StyleRule[]; 6 + export const variables: Record<string, string>; 7 + }
+13
packages/react-demo/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "jsx": "react-jsx", 4 + "target": "ES2022", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "strict": true, 8 + "esModuleInterop": true, 9 + "skipLibCheck": true, 10 + "noEmit": true 11 + }, 12 + "include": ["./**/*"] 13 + }
+22
packages/react-demo/vite.config.ts
··· 1 + import { defineConfig } from "vite"; 2 + import react from "@vitejs/plugin-react"; 3 + import { unionIR } from "@union-web/react-plugin/vite"; 4 + import { unionLint } from "@union-web/react-plugin"; 5 + import { fileURLToPath } from "url"; 6 + import path from "path"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + 10 + export default defineConfig({ 11 + optimizeDeps: { 12 + include: [], 13 + }, 14 + plugins: [ 15 + unionIR({ 16 + theme: path.resolve(__dirname, "../../input/themes/breeze/style.css"), 17 + colors: path.resolve(__dirname, "../../input/colors/BreezeDark.colors"), 18 + defaults: path.resolve(__dirname, "../../input/defaults/default.css"), 19 + }), 20 + react(), 21 + ], 22 + });
+12 -4
packages/react-plugin/package.json
··· 2 2 "name": "@union-web/react-plugin", 3 3 "version": "0.1.0", 4 4 "type": "module", 5 - "main": "src/index.ts", 6 - "types": "src/index.ts", 5 + "main": "dist/index.js", 6 + "types": "dist/index.d.ts", 7 7 "exports": { 8 8 ".": { 9 - "types": "./src/index.ts", 10 - "default": "./src/index.ts" 9 + "types": "./dist/index.d.ts", 10 + "default": "./dist/index.js" 11 + }, 12 + "./vite": { 13 + "types": "./dist/vite-plugin-ir.d.ts", 14 + "default": "./dist/vite-plugin-ir.js" 11 15 } 12 16 }, 17 + "scripts": { 18 + "build": "tsc" 19 + }, 13 20 "dependencies": { 14 21 "@union-web/core": "*", 15 22 "react": "^19.0.0" 16 23 }, 17 24 "devDependencies": { 25 + "@types/node": "^22.0.0", 18 26 "@types/react": "^19.0.0", 19 27 "typescript": "^5.5.0", 20 28 "vite": "^6.0.0"
+9 -2
packages/react-plugin/src/context.ts
··· 7 7 8 8 export const UnionIRContext = createContext<UnionIR>({ rules: [] }); 9 9 10 + export interface AncestorElementData { 11 + role: string; 12 + hints?: string[]; 13 + attributes?: Record<string, string>; 14 + ids?: string[]; 15 + } 16 + 10 17 export interface UnionAncestorData { 11 - roles: string[]; 18 + elements: AncestorElementData[]; 12 19 } 13 20 14 - export const UnionAncestorContext = createContext<UnionAncestorData>({ roles: [] }); 21 + export const UnionAncestorContext = createContext<UnionAncestorData>({ elements: [] });
+5 -3
packages/react-plugin/src/index.ts
··· 1 - export { useUnion } from './useUnion.jsx'; 2 - export type { UseUnionOptions, UseUnionResult } from './useUnion.jsx'; 1 + export { useUnion } from './useUnion.js'; 2 + export type { UseUnionOptions, UseUnionResult } from './useUnion.js'; 3 3 export { UnionIRProvider } from './UnionIRProvider.js'; 4 4 export type { UnionIRProviderProps } from './UnionIRProvider.js'; 5 5 export { UnionIRContext, UnionAncestorContext } from './context.js'; 6 - export type { UnionIR, UnionAncestorData } from './context.js'; 6 + export type { UnionIR, UnionAncestorData, AncestorElementData } from './context.js'; 7 7 export { unionLint } from './vite-plugin.js'; 8 + export { unionIR } from './vite-plugin-ir.js'; 9 + export type { UnionIROptions } from './vite-plugin-ir.js';
+138 -13
packages/react-plugin/src/useUnion.tsx
··· 1 - import { useContext, useMemo } from 'react'; 2 - import { createElement, resolveElement } from '@union-web/core'; 1 + import { useContext, useMemo, useEffect } from 'react'; 2 + import { createElement, resolveElement, matchRules } from '@union-web/core'; 3 3 import type { StylePropertyGroup } from '@union-web/core'; 4 4 import { UnionIRContext, UnionAncestorContext } from './context.js'; 5 + import type { AncestorElementData } from './context.js'; 5 6 6 7 const DEFAULT_STATES = [ 7 8 'hovered', ··· 32 33 role: string; 33 34 context?: string[] | null; 34 35 state?: string[]; 36 + hints?: string[]; 37 + attributes?: Record<string, string>; 38 + ids?: string[]; 39 + debug?: boolean; 35 40 } 36 41 37 42 export interface UseUnionResult { ··· 40 45 UnionProvider: ({ children }: { children: React.ReactNode }) => React.ReactElement; 41 46 } 42 47 48 + function formatGroup(g: StylePropertyGroup): Record<string, unknown> { 49 + const out: Record<string, unknown> = {}; 50 + for (const [key, val] of Object.entries(g)) { 51 + if (val !== undefined) { 52 + const obj = val as Record<string, unknown>; 53 + const defined: Record<string, unknown> = {}; 54 + for (const [k, v] of Object.entries(obj)) { 55 + if (v !== undefined) defined[k] = v; 56 + } 57 + if (Object.keys(defined).length > 0) out[key] = defined; 58 + } 59 + } 60 + return out; 61 + } 62 + 63 + function logMatchResult( 64 + role: string, 65 + state: string[], 66 + ancestors: string[], 67 + matches: { selector: string; specificity: string; declarations: Record<string, string> }[], 68 + result: StylePropertyGroup, 69 + ) { 70 + const stateLabel = state.length === 0 ? 'base' : state.join('+'); 71 + console.groupCollapsed( 72 + `%c[useUnion] %c${role}%c (${stateLabel}) %cancestors: [${ancestors.join(', ')}]`, 73 + 'color:#888', 74 + 'color:#0ae;font-weight:bold', 75 + 'color:#888', 76 + 'color:#666', 77 + ); 78 + if (matches.length === 0) { 79 + console.log('%cno matching rules', 'color:#f80'); 80 + } 81 + for (const m of matches) { 82 + console.groupCollapsed( 83 + `%c✓ %c${m.selector} %c(spec: ${m.specificity})`, 84 + 'color:#0c0', 85 + 'color:#fff;font-weight:bold', 86 + 'color:#888', 87 + ); 88 + console.table(m.declarations); 89 + console.groupEnd(); 90 + } 91 + console.log('%cresult:', 'color:#08f;font-weight:bold', formatGroup(result)); 92 + console.groupEnd(); 93 + } 94 + 43 95 export function useUnion(options: UseUnionOptions): UseUnionResult { 44 96 const ir = useContext(UnionIRContext); 45 97 const ancestorData = useContext(UnionAncestorContext); 46 98 47 - const context = options.context !== undefined ? options.context : ancestorData.roles; 48 - const effectiveContext = context === null ? [] : context; 99 + const effectiveAncestors: AncestorElementData[] = useMemo(() => { 100 + if (options.context !== undefined) { 101 + if (options.context === null) return []; 102 + return options.context.map(role => ({ role })); 103 + } 104 + return ancestorData.elements; 105 + }, [options.context, ancestorData.elements]); 49 106 50 107 const ancestorElements = useMemo( 51 - () => effectiveContext.map(role => createElement(role)), 108 + () => 109 + effectiveAncestors.map(a => 110 + createElement(a.role, { hints: a.hints, attributes: a.attributes, ids: a.ids }), 111 + ), 52 112 // eslint-disable-next-line react-hooks/exhaustive-deps 53 - [effectiveContext.join(',')], 113 + [JSON.stringify(effectiveAncestors)], 54 114 ); 55 115 56 116 const styles = useMemo(() => { ··· 58 118 const combos = getStateCombinations(DEFAULT_STATES); 59 119 for (const combo of combos) { 60 120 const key = combo.length === 0 ? 'base' : combo.join('+'); 61 - const el = createElement(options.role, { states: combo }); 121 + const el = createElement(options.role, { 122 + states: combo, 123 + hints: options.hints, 124 + attributes: options.attributes, 125 + ids: options.ids, 126 + }); 62 127 const ancestorEls = ancestorElements.map(a => ({ ...a })); 63 128 resolved[key] = resolveElement(ir.rules, el, ancestorEls); 64 129 } 65 130 return resolved; 66 - }, [options.role, ir.rules, ancestorElements]); 131 + }, [options.role, options.hints, options.attributes, options.ids, ir.rules, ancestorElements]); 67 132 68 133 const currentState = options.state ?? []; 69 134 const currentKey = currentState.length === 0 ? 'base' : currentState.join('+'); ··· 73 138 if (exactMatch) return exactMatch; 74 139 75 140 /* Unknown state combo — resolve on the fly */ 76 - const el = createElement(options.role, { states: currentState }); 141 + const el = createElement(options.role, { 142 + states: currentState, 143 + hints: options.hints, 144 + attributes: options.attributes, 145 + ids: options.ids, 146 + }); 77 147 const ancestorEls = ancestorElements.map(a => ({ ...a })); 78 148 return resolveElement(ir.rules, el, ancestorEls); 79 - }, [styles, currentKey, options.role, ir.rules, ancestorElements, currentState]); 149 + }, [styles, currentKey, options.role, options.hints, options.attributes, options.ids, ir.rules, ancestorElements, currentState]); 80 150 81 151 const UnionProvider = useMemo(() => { 82 - const nextRoles = context === null ? [options.role] : [...effectiveContext, options.role]; 83 - const nextData = { roles: nextRoles }; 152 + const currentAncestor: AncestorElementData = { 153 + role: options.role, 154 + hints: options.hints, 155 + attributes: options.attributes, 156 + ids: options.ids, 157 + }; 158 + const nextElements = 159 + options.context === null 160 + ? [currentAncestor] 161 + : [...effectiveAncestors, currentAncestor]; 162 + const nextData = { elements: nextElements }; 84 163 function Provider({ children }: { children: React.ReactNode }) { 85 164 return ( 86 165 <UnionAncestorContext.Provider value={nextData}> ··· 90 169 } 91 170 Provider.displayName = `UnionProvider(${options.role})`; 92 171 return Provider; 93 - }, [options.role, effectiveContext, context]); 172 + }, [options.role, options.hints, options.attributes, options.ids, effectiveAncestors, options.context]); 173 + 174 + useEffect(() => { 175 + if (!options.debug) return; 176 + const el = createElement(options.role, { 177 + states: currentState, 178 + hints: options.hints, 179 + attributes: options.attributes, 180 + ids: options.ids, 181 + }); 182 + const ancestorEls = ancestorElements.map(a => ({ ...a })); 183 + const matches = matchRules(ir.rules, el, ancestorEls); 184 + const seen = new Set<unknown>(); 185 + const uniqueMatches: { rule: typeof matches[number]['rule']; selector: typeof matches[number]['selector'] }[] = []; 186 + for (const m of matches) { 187 + if (!seen.has(m.rule)) { 188 + seen.add(m.rule); 189 + uniqueMatches.push(m); 190 + } 191 + } 192 + 193 + function atomToString(a: (typeof matches)[number]['selector']['atoms'][number]): string { 194 + switch (a.type) { 195 + case 'type': return a.name; 196 + case 'id': return `#${a.name}`; 197 + case 'state': return `:${a.name}`; 198 + case 'hint': return `[${a.name}]`; 199 + case 'any': return '*'; 200 + case 'attribute': return a.operator ? `[${a.name}${a.operator}${a.value ?? ''}]` : `[${a.name}]`; 201 + case 'pseudo-element': return `::${a.name}`; 202 + case 'child-combinator': return '>'; 203 + case 'descendant-combinator': return ' '; 204 + } 205 + } 206 + 207 + logMatchResult( 208 + options.role, 209 + currentState, 210 + effectiveAncestors.map(a => a.role), 211 + uniqueMatches.map(m => ({ 212 + selector: m.selector.atoms.map(atomToString).join(' '), 213 + specificity: `${m.selector.specificity.a}.${m.selector.specificity.b}.${m.selector.specificity.c}`, 214 + declarations: m.rule.declarations, 215 + })), 216 + resolvedCurrent, 217 + ); 218 + }); 94 219 95 220 return { styles, current: resolvedCurrent, UnionProvider }; 96 221 }
+112
packages/react-plugin/src/vite-plugin-ir.ts
··· 1 + import type { Plugin, ResolvedConfig } from 'vite'; 2 + import { 3 + parseStylesheet, parseIni, KColorSchemeResolver, VariableResolver, 4 + generateCssVariables, 5 + } from '@union-web/core'; 6 + import type { StyleRule } from '@union-web/core'; 7 + import * as fs from 'node:fs'; 8 + 9 + export interface UnionIROptions { 10 + theme: string; 11 + colors: string; 12 + defaults: string; 13 + } 14 + 15 + const VIRTUAL_MODULE_ID = 'virtual:union-ir'; 16 + const RESOLVED_PREFIX = '\0'; 17 + 18 + export function unionIR(options: UnionIROptions): Plugin { 19 + let rules: StyleRule[] = []; 20 + let resolvedVars: Record<string, string> = {}; 21 + let cssVarsBlock = ''; 22 + let config: ResolvedConfig; 23 + 24 + function resolveVars(value: string, vars: Record<string, string>): string { 25 + let resolved = value; 26 + let prev: string; 27 + do { 28 + prev = resolved; 29 + resolved = resolved.replace(/var\((--[^)]+)\)/g, (match, name) => vars[name] ?? match); 30 + } while (resolved !== prev); 31 + return resolved; 32 + } 33 + 34 + function loadRules() { 35 + const defs = parseStylesheet(options.defaults); 36 + const theme = parseStylesheet(options.theme); 37 + rules = [...defs.rules, ...theme.rules]; 38 + 39 + const colorsText = fs.readFileSync(options.colors, 'utf-8'); 40 + const colorsData = parseIni(colorsText); 41 + const colorResolver = new KColorSchemeResolver({ data: colorsData }); 42 + 43 + const allVars = { ...defs.variables, ...theme.variables }; 44 + const varResolver = new VariableResolver(allVars, colorResolver); 45 + resolvedVars = varResolver.resolveAll(); 46 + 47 + /* Resolve var() references in declarations so core parsers get concrete values */ 48 + for (const rule of rules) { 49 + rule.declarations = Object.fromEntries( 50 + Object.entries(rule.declarations).map(([k, v]) => [k, resolveVars(v, resolvedVars)]), 51 + ); 52 + } 53 + 54 + const kcsVars = generateCssVariables(colorResolver); 55 + const allCssVars = { ...resolvedVars }; 56 + for (const v of kcsVars) { 57 + allCssVars[v.name] = v.value; 58 + } 59 + 60 + cssVarsBlock = `:root {\n${Object.entries(allCssVars).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`; 61 + } 62 + 63 + return { 64 + name: '@union-web/react-plugin/ir', 65 + enforce: 'pre', 66 + 67 + configResolved(resolvedConfig) { 68 + config = resolvedConfig; 69 + }, 70 + 71 + buildStart() { 72 + loadRules(); 73 + }, 74 + 75 + resolveId(id) { 76 + if (id === VIRTUAL_MODULE_ID) return RESOLVED_PREFIX + VIRTUAL_MODULE_ID; 77 + }, 78 + 79 + load(id) { 80 + if (id !== RESOLVED_PREFIX + VIRTUAL_MODULE_ID) return; 81 + return `export const rules = ${JSON.stringify(rules)}; 82 + export const variables = ${JSON.stringify(resolvedVars)};`; 83 + }, 84 + 85 + transformIndexHtml() { 86 + return [ 87 + { 88 + tag: 'style', 89 + injectTo: 'head-prepend', 90 + children: cssVarsBlock, 91 + }, 92 + ]; 93 + }, 94 + 95 + configureServer(server) { 96 + const { theme, colors, defaults } = options; 97 + const watched = [theme, colors, defaults]; 98 + for (const file of watched) { 99 + if (server.watcher.add) { 100 + server.watcher.add(file); 101 + } 102 + } 103 + 104 + server.watcher.on('change', (path: string) => { 105 + if (watched.includes(path) || watched.some(w => path.startsWith(w.substring(0, w.lastIndexOf('/'))))) { 106 + loadRules(); 107 + server.ws.send({ type: 'full-reload' }); 108 + } 109 + }); 110 + }, 111 + }; 112 + }
+4 -1
packages/react-plugin/tsconfig.json
··· 2 2 "extends": "../../tsconfig.json", 3 3 "compilerOptions": { 4 4 "jsx": "react-jsx", 5 - "noEmit": true 5 + "outDir": "dist", 6 + "declaration": true, 7 + "declarationMap": true, 8 + "sourceMap": true 6 9 }, 7 10 "include": ["src/**/*"] 8 11 }