Commit ·
e92b981
1
Parent(s): 39309b1
VS Code extension 0.9.0: aura, texture and slider designers, CSS conversion
Browse filesLive webview designers that read and write DSSL, backed by pure modules and
tests against a fake vscode API and a real Chrome.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- dssl-vscode/PUBLISHING.md +10 -11
- dssl-vscode/README.md +22 -5
- dssl-vscode/extension.js +93 -1
- dssl-vscode/lib/analysis.js +46 -2
- dssl-vscode/lib/designer-host.js +48 -0
- dssl-vscode/lib/engine/aura.js +833 -0
- dssl-vscode/lib/engine/color.js +314 -0
- dssl-vscode/lib/engine/import.js +372 -0
- dssl-vscode/lib/engine/lint.js +133 -0
- dssl-vscode/lib/engine/parser.js +23 -23
- dssl-vscode/lib/engine/resolver.js +183 -100
- dssl-vscode/lib/engine/slider.js +250 -0
- dssl-vscode/lib/engine/texture.js +547 -0
- dssl-vscode/lib/engine/utils.js +9 -9
- dssl-vscode/lib/props-docs.js +13 -3
- dssl-vscode/media/designer-core.js +279 -0
- dssl-vscode/media/designer.css +66 -0
- dssl-vscode/media/designer.js +477 -0
- dssl-vscode/package.json +68 -7
dssl-vscode/PUBLISHING.md
CHANGED
|
@@ -12,21 +12,20 @@ there, "DSSL" shows up in the tab, searchable, one-click install, updates includ
|
|
| 12 |
One-time, on your side (about 2 minutes):
|
| 13 |
|
| 14 |
1. Go to https://open-vsx.org and sign in with your GitHub account.
|
| 15 |
-
2.
|
| 16 |
-
3.
|
| 17 |
-
|
| 18 |
-
mirrored into `.git/backups/`).
|
| 19 |
|
| 20 |
-
Then it is one command
|
| 21 |
|
| 22 |
```sh
|
| 23 |
-
bash scripts/publish-openvsx.sh
|
| 24 |
```
|
| 25 |
|
| 26 |
-
|
| 27 |
-
exists")
|
| 28 |
|
| 29 |
-
- VSCodium: Extensions tab
|
| 30 |
- Future updates: bump `version` in `dssl-vscode/package.json`, repackage with
|
| 31 |
`npx @vscode/vsce package --no-dependencies`, rerun the script.
|
| 32 |
|
|
@@ -40,7 +39,7 @@ gatekeeper: it requires an Azure DevOps organization and a personal access token
|
|
| 40 |
Suggested ID: `ehs-studio`.
|
| 41 |
3. In Azure DevOps: User settings → **Personal access tokens** → New token with
|
| 42 |
scope **Marketplace → Manage**.
|
| 43 |
-
4.
|
| 44 |
|
| 45 |
```sh
|
| 46 |
cd dssl-vscode && npx @vscode/vsce publish --pat <token>
|
|
@@ -58,4 +57,4 @@ This can happen at any time; Open VSX first does not block it.
|
|
| 58 |
|
| 59 |
- `node scripts/sync-extension-engine.mjs` after ANY engine change, so the
|
| 60 |
bundled compiler matches the browser compiler.
|
| 61 |
-
- Run
|
|
|
|
| 12 |
One-time, on your side (about 2 minutes):
|
| 13 |
|
| 14 |
1. Go to https://open-vsx.org and sign in with your GitHub account.
|
| 15 |
+
2. Open your profile, then **Settings**, then **Access Tokens**, and generate a token.
|
| 16 |
+
3. Accept the Eclipse publisher agreement on the same page. Publishing fails without it.
|
| 17 |
+
4. Put the token in a file that is not in the repo, for example `~/Dokumente/tokens/openvsx token`.
|
|
|
|
| 18 |
|
| 19 |
+
Then it is one command:
|
| 20 |
|
| 21 |
```sh
|
| 22 |
+
OPENVSX_TOKEN_FILE="$HOME/Dokumente/tokens/openvsx token" bash scripts/publish-openvsx.sh
|
| 23 |
```
|
| 24 |
|
| 25 |
+
(`OPENVSX_TOKEN` or a first argument work too.) The script creates the `ehs-studio` namespace
|
| 26 |
+
(tolerating "already exists") and publishes the newest `.vsix` in this folder. After that:
|
| 27 |
|
| 28 |
+
- VSCodium: Extensions tab, search "DSSL", Install.
|
| 29 |
- Future updates: bump `version` in `dssl-vscode/package.json`, repackage with
|
| 30 |
`npx @vscode/vsce package --no-dependencies`, rerun the script.
|
| 31 |
|
|
|
|
| 39 |
Suggested ID: `ehs-studio`.
|
| 40 |
3. In Azure DevOps: User settings → **Personal access tokens** → New token with
|
| 41 |
scope **Marketplace → Manage**.
|
| 42 |
+
4. Keep the token in a file outside the repo, then it is:
|
| 43 |
|
| 44 |
```sh
|
| 45 |
cd dssl-vscode && npx @vscode/vsce publish --pat <token>
|
|
|
|
| 57 |
|
| 58 |
- `node scripts/sync-extension-engine.mjs` after ANY engine change, so the
|
| 59 |
bundled compiler matches the browser compiler.
|
| 60 |
+
- Run `npm run test:fast` and `node scripts/test-designer-host.mjs` before packaging. The second one loads the real `extension.js` against a fake editor API, which is the closest thing to opening it in VS Code. Do also open the packaged `.vsix` in VS Code once.
|
dssl-vscode/README.md
CHANGED
|
@@ -8,9 +8,9 @@ Works in **VS Code** and **VSCodium** (same extension API; install the `.vsix` i
|
|
| 8 |
via Extensions panel → ⋯ → *Install from VSIX*).
|
| 9 |
|
| 10 |
Extension id: **`ehs-studio.dssl-vscode`**. An Open VSX listing (the registry VSCodium's
|
| 11 |
-
Extensions tab searches) is staged:
|
| 12 |
-
`scripts/publish-openvsx.sh` puts it in the tab
|
| 13 |
-
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
|
@@ -22,6 +22,23 @@ that flow and the Microsoft Marketplace flow.
|
|
| 22 |
- **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
|
| 23 |
for tokens (resolved value), for workspace motions and behaviors.
|
| 24 |
- **Syntax highlighting** via a TextMate grammar for `.dssl`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
## What it does not do (yet)
|
| 27 |
|
|
@@ -37,8 +54,8 @@ that flow and the Microsoft Marketplace flow.
|
|
| 37 |
|
| 38 |
## How it works
|
| 39 |
|
| 40 |
-
`lib/engine/` holds a synced copy of `
|
| 41 |
-
main repo (keep them identical with `scripts/sync-extension-engine.mjs`). On change, every
|
| 42 |
`.dssl` file in the workspace is parsed once, tokens fold in load order, and the real
|
| 43 |
`resolve()` produces the diagnostics. Completions and hovers read the folded registry.
|
| 44 |
|
|
|
|
| 8 |
via Extensions panel → ⋯ → *Install from VSIX*).
|
| 9 |
|
| 10 |
Extension id: **`ehs-studio.dssl-vscode`**. An Open VSX listing (the registry VSCodium's
|
| 11 |
+
Extensions tab searches) is staged: it needs an Open VSX access token, then
|
| 12 |
+
`scripts/publish-openvsx.sh` puts it in the tab. See PUBLISHING.md for that flow and the
|
| 13 |
+
Microsoft Marketplace one.
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
|
|
|
| 22 |
- **Hover docs** for the semantic vocabulary (`row:`, `bend:`, `stretch:`, `seen`, `pin`...),
|
| 23 |
for tokens (resolved value), for workspace motions and behaviors.
|
| 24 |
- **Syntax highlighting** via a TextMate grammar for `.dssl`.
|
| 25 |
+
- **Completions for the new vocabulary.** After `texture:` you get the 30 presets, the generators and
|
| 26 |
+
every setting; after `aura-glow:` only glow settings; after `aura-particle:` only particle settings
|
| 27 |
+
(shapes, where particles are born, directions); after `slider:` the clauses. Hovering a preset or a
|
| 28 |
+
setting explains it.
|
| 29 |
+
- **The designer** (*DSSL: Open the designer*, or the paint-can button on a `.dssl` tab). Three tabs:
|
| 30 |
+
- *Aura*: a slider or picker for every glow and particle setting, colour stops you can add and
|
| 31 |
+
remove, a live preview drawn by the real renderer, a graph of one particle's opacity and size over
|
| 32 |
+
its life, and a live graph of particles alive and frame time.
|
| 33 |
+
- *Texture*: all 30 presets and 10 generators as thumbnails, up to four stacked layers, every setting
|
| 34 |
+
as a slider, colour pickers.
|
| 35 |
+
- *Slider*: a live range input with the generated rules, and a bar editor for histogram sliders.
|
| 36 |
+
|
| 37 |
+
Each tab writes the DSSL for you and checks it with the real compiler as you go. **Insert at cursor**
|
| 38 |
+
puts it in your file (indented to match, replacing a selection if there is one). **Load from selection**
|
| 39 |
+
reads an existing `aura-glow:`, `texture:` or `slider:` declaration back into the controls.
|
| 40 |
+
- **DSSL: Convert a CSS file to DSSL** (right-click a `.css` file). Runs the importer, writes the `.dssl`
|
| 41 |
+
next to it, and lists in an output channel what it could not carry over.
|
| 42 |
|
| 43 |
## What it does not do (yet)
|
| 44 |
|
|
|
|
| 54 |
|
| 55 |
## How it works
|
| 56 |
|
| 57 |
+
`lib/engine/` holds a synced copy of the engine's modules (`parser`, `resolver`, `utils`, `color`, `texture`,
|
| 58 |
+
`aura`, `slider`, `lint`, `import`) from the main repo (keep them identical with `scripts/sync-extension-engine.mjs`). On change, every
|
| 59 |
`.dssl` file in the workspace is parsed once, tokens fold in load order, and the real
|
| 60 |
`resolve()` produces the diagnostics. Completions and hovers read the folded registry.
|
| 61 |
|
dssl-vscode/extension.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* extension.js
|
| 2 |
* The analysis layer (and the DSSL compiler it wraps) is ESM; the extension
|
| 3 |
* host is happy with a dynamic import from CommonJS. Everything testable
|
| 4 |
* lives in lib/, this file only talks to the editor. */
|
|
@@ -9,6 +9,9 @@ let mod = null; // lib/analysis.js module
|
|
| 9 |
let analysis = null; // current workspace analysis result
|
| 10 |
let diags = null; // DiagnosticCollection (created once)
|
| 11 |
let debounceTimer = null;
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const KIND = {
|
| 14 |
property: vscode.CompletionItemKind.Property,
|
|
@@ -90,6 +93,88 @@ function registerHover() {
|
|
| 90 |
});
|
| 91 |
}
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
async function activate(context) {
|
| 94 |
mod = await import('./lib/analysis.js');
|
| 95 |
diags = vscode.languages.createDiagnosticCollection('dssl');
|
|
@@ -103,7 +188,14 @@ async function activate(context) {
|
|
| 103 |
vscode.workspace.onDidChangeWorkspaceFolders(() => scheduleAnalysis()),
|
| 104 |
registerCompletion(),
|
| 105 |
registerHover(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
);
|
|
|
|
| 107 |
|
| 108 |
// run once so diagnostics are present before the first keystroke
|
| 109 |
await runAnalysis();
|
|
|
|
| 1 |
+
/* extension.js - thin vscode wiring over lib/analysis.js.
|
| 2 |
* The analysis layer (and the DSSL compiler it wraps) is ESM; the extension
|
| 3 |
* host is happy with a dynamic import from CommonJS. Everything testable
|
| 4 |
* lives in lib/, this file only talks to the editor. */
|
|
|
|
| 9 |
let analysis = null; // current workspace analysis result
|
| 10 |
let diags = null; // DiagnosticCollection (created once)
|
| 11 |
let debounceTimer = null;
|
| 12 |
+
let designerHost = null; // lib/designer-host.js
|
| 13 |
+
let designerPanel = null;
|
| 14 |
+
let lastDsslEditor = null; // the panel steals focus, so remember which .dssl editor to insert into
|
| 15 |
|
| 16 |
const KIND = {
|
| 17 |
property: vscode.CompletionItemKind.Property,
|
|
|
|
| 93 |
});
|
| 94 |
}
|
| 95 |
|
| 96 |
+
/* ---- the designer panel ---------------------------------------------- */
|
| 97 |
+
|
| 98 |
+
/** the .dssl editor to talk to: the last one the user was in, if it is still on screen */
|
| 99 |
+
function findDsslEditor() {
|
| 100 |
+
const visible = vscode.window.visibleTextEditors.filter((e) => e.document.languageId === 'dssl');
|
| 101 |
+
if (lastDsslEditor && visible.includes(lastDsslEditor)) return lastDsslEditor;
|
| 102 |
+
return visible[0] || null;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/** the editor, as designer-host.js wants it. New lines keep the indentation of the line you insert on. */
|
| 106 |
+
function editorBridge() {
|
| 107 |
+
return {
|
| 108 |
+
activeDsslEditor() {
|
| 109 |
+
const ed = findDsslEditor();
|
| 110 |
+
if (!ed) return null;
|
| 111 |
+
return {
|
| 112 |
+
async insert(text) {
|
| 113 |
+
const sel = ed.selection;
|
| 114 |
+
const indent = (ed.document.lineAt(sel.start.line).text.match(/^\s*/) || [''])[0];
|
| 115 |
+
const body = text.replace(/\n/g, '\n' + indent);
|
| 116 |
+
await ed.edit((b) => (sel.isEmpty ? b.insert(sel.active, body) : b.replace(sel, body)));
|
| 117 |
+
},
|
| 118 |
+
selectionText() { return ed.document.getText(ed.selection); },
|
| 119 |
+
};
|
| 120 |
+
},
|
| 121 |
+
copy: (text) => vscode.env.clipboard.writeText(text),
|
| 122 |
+
info: (m) => vscode.window.setStatusBarMessage('DSSL designer: ' + m, 4000),
|
| 123 |
+
post: (m) => designerPanel && designerPanel.webview.postMessage(m),
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
async function openDesigner(context, tab) {
|
| 128 |
+
if (!designerHost) designerHost = await import('./lib/designer-host.js');
|
| 129 |
+
if (designerPanel) {
|
| 130 |
+
designerPanel.reveal(undefined, true);
|
| 131 |
+
designerPanel.webview.postMessage({ type: 'tab', tab });
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
const column = vscode.window.activeTextEditor ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active;
|
| 135 |
+
designerPanel = vscode.window.createWebviewPanel('dsslDesigner', 'DSSL designer', { viewColumn: column, preserveFocus: true }, {
|
| 136 |
+
enableScripts: true,
|
| 137 |
+
retainContextWhenHidden: true,
|
| 138 |
+
localResourceRoots: [context.extensionUri],
|
| 139 |
+
});
|
| 140 |
+
const uri = (rel) => designerPanel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, ...rel.split('/'))).toString();
|
| 141 |
+
designerPanel.webview.html = designerHost.designerHtml({ cspSource: designerPanel.webview.cspSource, uri, tab });
|
| 142 |
+
const bridge = editorBridge();
|
| 143 |
+
designerPanel.webview.onDidReceiveMessage((msg) => designerHost.handleMessage(msg, bridge).catch((e) => vscode.window.showErrorMessage('DSSL designer: ' + e.message)));
|
| 144 |
+
designerPanel.onDidDispose(() => { designerPanel = null; });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ---- CSS to DSSL -------------------------------------------------------- */
|
| 148 |
+
|
| 149 |
+
let importChannel = null;
|
| 150 |
+
|
| 151 |
+
async function convertCss(uri) {
|
| 152 |
+
const target = uri || (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.uri);
|
| 153 |
+
if (!target || !/\.css$/i.test(target.fsPath)) { vscode.window.showInformationMessage('DSSL: pick a .css file to convert (right-click it in the Explorer, or open it first).'); return; }
|
| 154 |
+
const { importCss } = await import('./lib/engine/import.js');
|
| 155 |
+
const text = Buffer.from(await vscode.workspace.fs.readFile(target)).toString('utf8');
|
| 156 |
+
const { dssl, report } = importCss(text);
|
| 157 |
+
const out = vscode.Uri.file(target.fsPath.replace(/\.css$/i, '.dssl'));
|
| 158 |
+
let exists = true;
|
| 159 |
+
try { await vscode.workspace.fs.stat(out); } catch { exists = false; }
|
| 160 |
+
if (exists) {
|
| 161 |
+
const pick = await vscode.window.showWarningMessage(`${out.fsPath.split(/[\\/]/).pop()} already exists.`, { modal: true }, 'Overwrite');
|
| 162 |
+
if (pick !== 'Overwrite') return;
|
| 163 |
+
}
|
| 164 |
+
await vscode.workspace.fs.writeFile(out, Buffer.from(dssl, 'utf8'));
|
| 165 |
+
if (!importChannel) importChannel = vscode.window.createOutputChannel('DSSL import');
|
| 166 |
+
importChannel.clear();
|
| 167 |
+
importChannel.appendLine(`Converted ${target.fsPath}`);
|
| 168 |
+
importChannel.appendLine(` ${report.rules} rule(s), ${report.declarations} declaration(s), ${report.converted} rewritten, ${report.pinned} moved to pin { }, ${report.motions} motion(s), ${report.raw} at-rule(s) kept in raw { }`);
|
| 169 |
+
for (const d of report.dropped) importChannel.appendLine(` dropped line ${d.line}: ${d.text}\n ${d.reason}`);
|
| 170 |
+
for (const n of report.notes) importChannel.appendLine(` note line ${n.line}: ${n.message}${n.hint ? '\n ' + n.hint : ''}`);
|
| 171 |
+
importChannel.appendLine(report.compile.ok ? ' The result compiles.' : ` The result still has ${report.compile.errors.length} error(s).`);
|
| 172 |
+
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(out), { preview: false });
|
| 173 |
+
const bits = [`${report.converted} values rewritten`];
|
| 174 |
+
if (report.dropped.length) bits.push(`${report.dropped.length} line(s) dropped`);
|
| 175 |
+
vscode.window.showInformationMessage(`DSSL: converted to ${out.fsPath.split(/[\\/]/).pop()} (${bits.join(', ')}).`, 'Show the report').then((c) => { if (c) importChannel.show(true); });
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
async function activate(context) {
|
| 179 |
mod = await import('./lib/analysis.js');
|
| 180 |
diags = vscode.languages.createDiagnosticCollection('dssl');
|
|
|
|
| 188 |
vscode.workspace.onDidChangeWorkspaceFolders(() => scheduleAnalysis()),
|
| 189 |
registerCompletion(),
|
| 190 |
registerHover(),
|
| 191 |
+
vscode.window.onDidChangeActiveTextEditor((ed) => { if (ed && ed.document.languageId === 'dssl') lastDsslEditor = ed; }),
|
| 192 |
+
vscode.commands.registerCommand('dssl.openDesigner', () => openDesigner(context, 'aura')),
|
| 193 |
+
vscode.commands.registerCommand('dssl.openAuraDesigner', () => openDesigner(context, 'aura')),
|
| 194 |
+
vscode.commands.registerCommand('dssl.openTextureDesigner', () => openDesigner(context, 'texture')),
|
| 195 |
+
vscode.commands.registerCommand('dssl.openSliderDesigner', () => openDesigner(context, 'slider')),
|
| 196 |
+
vscode.commands.registerCommand('dssl.convertCss', (uri) => convertCss(uri)),
|
| 197 |
);
|
| 198 |
+
if (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.languageId === 'dssl') lastDsslEditor = vscode.window.activeTextEditor;
|
| 199 |
|
| 200 |
// run once so diagnostics are present before the first keystroke
|
| 201 |
await runAnalysis();
|
dssl-vscode/lib/analysis.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* analysis.js
|
| 2 |
* extension layer feeds it text and offsets, tests feed it strings.
|
| 3 |
* The point of this module: diagnostics come from the REAL parser and
|
| 4 |
* resolver (the same code the browser runs), and completions come from the
|
|
@@ -7,11 +7,52 @@
|
|
| 7 |
import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
|
| 8 |
import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
|
| 9 |
import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
/* ---- workspace analysis ------------------------------------------------ */
|
| 12 |
|
| 13 |
/* docs: Map<absolute path, text>. Fold order: tokens*.dssl first, then
|
| 14 |
-
* alphabetical
|
| 15 |
export function analyzeWorkspace(docs) {
|
| 16 |
const paths = [...docs.keys()].sort((a, b) => {
|
| 17 |
const ta = /tokens[^/]*\.dssl$/.test(a) ? 0 : 1;
|
|
@@ -174,6 +215,7 @@ export function completionsAt(analysis, path, text, offset) {
|
|
| 174 |
items.push({ label: k, kind: 'keyword', detail: 'animate option', documentation: ANIMATE_EXTRAS[k] || 'animate option' });
|
| 175 |
}
|
| 176 |
}
|
|
|
|
| 177 |
for (const k of CSS_KEYWORDS) {
|
| 178 |
items.push({ label: k, kind: 'keyword', detail: 'keyword' });
|
| 179 |
}
|
|
@@ -241,6 +283,8 @@ export function hoverAt(analysis, path, text, offset) {
|
|
| 241 |
if (BLOCKS[clean]) return { contents: `**${clean}** \u2014 ${BLOCKS[clean]}` };
|
| 242 |
if (AT_RULES[clean]) return { contents: `**${clean}** \u2014 ${AT_RULES[clean]}` };
|
| 243 |
if (CSS[clean]) return { contents: `**${clean}** \u2014 ${CSS[clean]}` };
|
|
|
|
|
|
|
| 244 |
if (analysis.motions.has(clean)) {
|
| 245 |
return { contents: `**${clean}** _motion_ \u2014 keyframe animation. Run with \`animate: ${clean} 600 ease-out\`.` };
|
| 246 |
}
|
|
|
|
| 1 |
+
/* analysis.js - pure analysis over .dssl text. No vscode imports here; the
|
| 2 |
* extension layer feeds it text and offsets, tests feed it strings.
|
| 3 |
* The point of this module: diagnostics come from the REAL parser and
|
| 4 |
* resolver (the same code the browser runs), and completions come from the
|
|
|
|
| 7 |
import { parse, PSEUDO_ELEMENTS } from './engine/parser.js';
|
| 8 |
import { resolve, DEFAULT_TOKENS, CSS_KEYWORDS } from './engine/resolver.js';
|
| 9 |
import { SEMANTIC, CSS, STATES, AT_RULES, BLOCKS, PROPERTY_NAMES } from './props-docs.js';
|
| 10 |
+
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, BLEND_MODES } from './engine/texture.js';
|
| 11 |
+
import { GLOW_KEYS, PARTICLE_KEYS, SHAPES, FROM_WORDS, DIR_WORDS } from './engine/aura.js';
|
| 12 |
+
import { SLIDER_CLAUSES, THUMB_SHAPES } from './engine/slider.js';
|
| 13 |
+
|
| 14 |
+
/* ---- the 0.9.0 vocabulary: what to offer after `texture:`, `aura-glow:`, `slider:` ... ----- */
|
| 15 |
+
const AURA_PROP = /^(aura|aura-glow|aura-particles?|glow-particles?|aura-glow-particles?)$/;
|
| 16 |
+
function domainItems(prop) {
|
| 17 |
+
const items = [];
|
| 18 |
+
const add = (label, detail, documentation, kind = 'keyword') => items.push({ label, kind, detail, documentation });
|
| 19 |
+
if (prop === 'texture') {
|
| 20 |
+
for (const n of PRESET_NAMES) add(n, 'texture preset', PRESETS[n].doc, 'value');
|
| 21 |
+
for (const g of GENERATORS) add(g, 'texture generator', 'start from scratch with this generator, then add settings');
|
| 22 |
+
for (const [k, d] of Object.entries(TEXTURE_KEYS)) add(k, 'texture setting', d.doc);
|
| 23 |
+
for (const b of BLEND_MODES) add(b, 'blend mode', 'how the texture mixes with what is under it');
|
| 24 |
+
} else if (prop === 'slider') {
|
| 25 |
+
for (const [k, usage] of Object.entries(SLIDER_CLAUSES)) add(k, 'slider setting', usage);
|
| 26 |
+
for (const t of THUMB_SHAPES) add(t, 'thumb shape', 'shape ' + t);
|
| 27 |
+
} else if (AURA_PROP.test(prop)) {
|
| 28 |
+
const glowOnly = prop === 'aura-glow';
|
| 29 |
+
const partsOnly = /particle/.test(prop) && prop !== 'aura-glow-particle' && prop !== 'aura-glow-particles';
|
| 30 |
+
if (prop === 'aura') { add('glow', 'aura layer', 'a soft light around the element'); add('particles', 'aura layer', 'things that leave the element'); add('none', 'clears', 'remove the whole aura'); }
|
| 31 |
+
if (!partsOnly) for (const [k, d] of Object.entries(GLOW_KEYS)) add(k, 'glow setting', d.doc);
|
| 32 |
+
if (!glowOnly) for (const [k, d] of Object.entries(PARTICLE_KEYS)) add(k, 'particle setting', d.doc);
|
| 33 |
+
if (!glowOnly) {
|
| 34 |
+
for (const s of SHAPES) add(s, 'particle shape', 'shape ' + s);
|
| 35 |
+
for (const f of FROM_WORDS) add(f, 'where particles are born', 'from ' + f);
|
| 36 |
+
for (const d of Object.keys(DIR_WORDS)) add(d, 'direction', 'dir ' + d);
|
| 37 |
+
}
|
| 38 |
+
for (const n of PRESET_NAMES) add(`texture(${n})`, 'texture inside an aura', PRESETS[n].doc);
|
| 39 |
+
}
|
| 40 |
+
return items;
|
| 41 |
+
}
|
| 42 |
+
function domainHover(word) {
|
| 43 |
+
if (PRESETS[word]) return `**${word}** _texture preset_ \u2014 ${PRESETS[word].doc}. Use it as \`texture: ${word}\`.`;
|
| 44 |
+
if (GENERATORS.includes(word)) return `**${word}** _texture generator_ \u2014 start from scratch with it, then add settings.`;
|
| 45 |
+
if (TEXTURE_KEYS[word]) return `**${word}** _texture setting_ \u2014 ${TEXTURE_KEYS[word].doc}`;
|
| 46 |
+
if (GLOW_KEYS[word]) return `**${word}** _glow setting_ \u2014 ${GLOW_KEYS[word].doc}`;
|
| 47 |
+
if (PARTICLE_KEYS[word]) return `**${word}** _particle setting_ \u2014 ${PARTICLE_KEYS[word].doc}`;
|
| 48 |
+
if (SLIDER_CLAUSES[word]) return `**${word}** _slider setting_ \u2014 \`${SLIDER_CLAUSES[word]}\``;
|
| 49 |
+
return null;
|
| 50 |
+
}
|
| 51 |
|
| 52 |
/* ---- workspace analysis ------------------------------------------------ */
|
| 53 |
|
| 54 |
/* docs: Map<absolute path, text>. Fold order: tokens*.dssl first, then
|
| 55 |
+
* alphabetical - the same convention the site uses (tokens first). */
|
| 56 |
export function analyzeWorkspace(docs) {
|
| 57 |
const paths = [...docs.keys()].sort((a, b) => {
|
| 58 |
const ta = /tokens[^/]*\.dssl$/.test(a) ? 0 : 1;
|
|
|
|
| 215 |
items.push({ label: k, kind: 'keyword', detail: 'animate option', documentation: ANIMATE_EXTRAS[k] || 'animate option' });
|
| 216 |
}
|
| 217 |
}
|
| 218 |
+
items.push(...domainItems(prop));
|
| 219 |
for (const k of CSS_KEYWORDS) {
|
| 220 |
items.push({ label: k, kind: 'keyword', detail: 'keyword' });
|
| 221 |
}
|
|
|
|
| 283 |
if (BLOCKS[clean]) return { contents: `**${clean}** \u2014 ${BLOCKS[clean]}` };
|
| 284 |
if (AT_RULES[clean]) return { contents: `**${clean}** \u2014 ${AT_RULES[clean]}` };
|
| 285 |
if (CSS[clean]) return { contents: `**${clean}** \u2014 ${CSS[clean]}` };
|
| 286 |
+
const dh = domainHover(clean);
|
| 287 |
+
if (dh) return { contents: dh };
|
| 288 |
if (analysis.motions.has(clean)) {
|
| 289 |
return { contents: `**${clean}** _motion_ \u2014 keyframe animation. Run with \`animate: ${clean} 600 ease-out\`.` };
|
| 290 |
}
|
dssl-vscode/lib/designer-host.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* designer-host.js: the editor-side half of the designer, with no vscode import so it can be tested.
|
| 2 |
+
* extension.js hands it the real vscode API; the tests hand it a fake one. */
|
| 3 |
+
|
| 4 |
+
const TABS = ['aura', 'texture', 'slider'];
|
| 5 |
+
|
| 6 |
+
/** the HTML shell for the panel. `uri('media/designer.js')` turns an extension-relative path into a webview URI. */
|
| 7 |
+
export function designerHtml({ cspSource, uri, tab = 'aura' }) {
|
| 8 |
+
const t = TABS.includes(tab) ? tab : 'aura';
|
| 9 |
+
// The panel needs: its own module scripts (script-src), data: images for textures, and
|
| 10 |
+
// inline <style> for the slider preview (style-src 'unsafe-inline'); nothing from the network.
|
| 11 |
+
const csp = `default-src 'none'; img-src ${cspSource} data:; style-src ${cspSource} 'unsafe-inline'; script-src ${cspSource}; font-src ${cspSource};`;
|
| 12 |
+
return `<!doctype html>
|
| 13 |
+
<html lang="en"><head><meta charset="utf-8">
|
| 14 |
+
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
| 15 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 16 |
+
<title>DSSL designer</title>
|
| 17 |
+
<link rel="stylesheet" href="${uri('media/designer.css')}">
|
| 18 |
+
</head><body data-tab="${t}">
|
| 19 |
+
<div id="app"></div>
|
| 20 |
+
<script type="module" src="${uri('media/designer.js')}"></script>
|
| 21 |
+
</body></html>`;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Handle a message from the panel. `api` is the editor, however it is spelled:
|
| 26 |
+
* api.activeDsslEditor() -> { insert(text), replaceSelection(text), selectionText() } | null
|
| 27 |
+
* api.copy(text) -> Promise
|
| 28 |
+
* api.info(message) show a small notice
|
| 29 |
+
* api.post(message) send a message to the panel
|
| 30 |
+
*/
|
| 31 |
+
export async function handleMessage(msg, api) {
|
| 32 |
+
if (!msg || typeof msg !== 'object') return;
|
| 33 |
+
if (msg.type === 'insert') {
|
| 34 |
+
const ed = api.activeDsslEditor();
|
| 35 |
+
if (!ed) { api.info('Open a .dssl file first, then press Insert. The text is still in the box: use Copy.'); return; }
|
| 36 |
+
if (typeof msg.text !== 'string' || !msg.text) return;
|
| 37 |
+
await ed.insert(msg.text);
|
| 38 |
+
api.info('Inserted.');
|
| 39 |
+
} else if (msg.type === 'copy') {
|
| 40 |
+
if (typeof msg.text === 'string') await api.copy(msg.text);
|
| 41 |
+
} else if (msg.type === 'requestSelection') {
|
| 42 |
+
const ed = api.activeDsslEditor();
|
| 43 |
+
if (!ed) { api.info('Open a .dssl file and select a declaration (for example aura-glow: ...) to load it.'); return; }
|
| 44 |
+
api.post({ type: 'selection', text: ed.selectionText() });
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export const DESIGNER_TABS = TABS;
|
dssl-vscode/lib/engine/aura.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: aura.js
|
| 3 |
+
*
|
| 4 |
+
* .orb { aura-glow: color.accent 28 pulse 1600 }
|
| 5 |
+
* .orb { aura-particle: #7cf -> transparent count 40 dir up from bottom }
|
| 6 |
+
* .orb { aura: glow #ff7a00 30 flicker 0.3, particles #fc4 -> #f30 count 60 shape flame glowing }
|
| 7 |
+
*
|
| 8 |
+
* An aura is light and particles around an element. It has NO presets: fire,
|
| 9 |
+
* an energy aura, snow and sparks are all just parameters, and you name your
|
| 10 |
+
* own with a `behavior` block. What you get is the set of knobs.
|
| 11 |
+
*
|
| 12 |
+
* Two kinds of layer:
|
| 13 |
+
* glow a soft light around the element (color, radius, pulse, flicker)
|
| 14 |
+
* particles things that leave the element (count, size, life, speed, dir ...)
|
| 15 |
+
*
|
| 16 |
+
* Properties (all of them end up in the same two slots, so they stack with the cascade):
|
| 17 |
+
* aura: glow and/or particles, comma separated. Replaces both slots.
|
| 18 |
+
* aura-glow: the glow slot
|
| 19 |
+
* aura-particle(s): the particles slot
|
| 20 |
+
* glow-particle(s): the particles slot, every particle glowing
|
| 21 |
+
* aura-glow-particle(s): both slots at once; settings are shared
|
| 22 |
+
*
|
| 23 |
+
* This file has two halves. The top is PURE (parseAura, no DOM) and runs in the
|
| 24 |
+
* compiler. The bottom is the canvas RUNTIME (AuraManager) that the engine drives.
|
| 25 |
+
* ========================================================================= */
|
| 26 |
+
|
| 27 |
+
import { parseColor, toRgba, withAlpha } from './color.js';
|
| 28 |
+
import { parseTexture, makeMask, splitWords, rng } from './texture.js';
|
| 29 |
+
|
| 30 |
+
/* ------------------------------------------------------------------ *
|
| 31 |
+
* vocabulary
|
| 32 |
+
* ------------------------------------------------------------------ */
|
| 33 |
+
|
| 34 |
+
export const FROM_WORDS = ['edge', 'bottom', 'top', 'left', 'right', 'center', 'corners', 'thumb'];
|
| 35 |
+
export const SHAPES = ['circle', 'spark', 'square', 'ring', 'star', 'flame', 'smoke', 'line'];
|
| 36 |
+
export const DIR_WORDS = { up: -90, down: 90, left: 180, right: 0, out: 'out', in: 'in' };
|
| 37 |
+
export const AURA_BLENDS = ['add', 'normal'];
|
| 38 |
+
|
| 39 |
+
const num = (min, max, extra = {}) => ({ kind: 'number', words: 1, min, max, ...extra });
|
| 40 |
+
|
| 41 |
+
export const GLOW_KEYS = {
|
| 42 |
+
color: { kind: 'color', words: 1, doc: 'the light colour (default: the element\'s text colour)' },
|
| 43 |
+
radius: num(1, 400, { doc: 'how far the glow reaches, in px' }),
|
| 44 |
+
intensity: num(0, 4, { doc: 'brightness, 1 is normal' }),
|
| 45 |
+
pulse: num(0, 60000, { doc: 'milliseconds for one breath in and out; 0 is steady' }),
|
| 46 |
+
depth: num(0, 1, { doc: 'how deep the pulse goes, 0 to 1' }),
|
| 47 |
+
flicker: num(0, 1, { doc: 'random flicker, like a flame: 0 is steady, 1 is wild' }),
|
| 48 |
+
seed: num(0, 99999, { int: true, doc: 'a different random flicker with the same look' }),
|
| 49 |
+
texture: { kind: 'texture', words: 1, doc: 'texture(grain scale 2): break the light up with a texture' },
|
| 50 |
+
essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export const PARTICLE_KEYS = {
|
| 54 |
+
color: { kind: 'colors', words: 1, doc: 'colours over a particle\'s life: color #ff0 -> #f60 -> transparent' },
|
| 55 |
+
count: num(1, 600, { int: true, doc: 'how many particles are alive at once' }),
|
| 56 |
+
size: { kind: 'range', words: 2, min: 0.5, max: 300, doc: 'diameter in px: size 4, or size 10 4 (start and end of a random range)' },
|
| 57 |
+
life: { kind: 'range', words: 2, min: 50, max: 60000, doc: 'how long one lives, in ms: life 900, or life 600 1200' },
|
| 58 |
+
speed: { kind: 'range', words: 2, min: 0, max: 3000, doc: 'px per second: speed 60, or speed 30 90' },
|
| 59 |
+
dir: { kind: 'dir', words: 1, doc: 'where they head: up, down, left, right, out, in, or degrees (-90 is up)' },
|
| 60 |
+
spread: num(0, 360, { doc: 'how wide the fan of directions is, in degrees' }),
|
| 61 |
+
gravity: num(-2000, 2000, { doc: 'px/s squared; negative pulls up (flames), positive down (snow)' }),
|
| 62 |
+
drag: num(0, 10, { doc: 'air resistance: how fast particles slow down' }),
|
| 63 |
+
turbulence: num(0, 1000, { doc: 'random sideways wobble, px/s' }),
|
| 64 |
+
from: { kind: 'word', words: 1, values: FROM_WORDS, doc: 'where they are born: edge, bottom, top, left, right, center, corners, thumb (range sliders)' },
|
| 65 |
+
shape: { kind: 'word', words: 1, values: SHAPES, doc: 'circle, spark, square, ring, star, flame, smoke or line' },
|
| 66 |
+
grow: num(0, 10, { doc: 'size at the end of life as a multiple of the start: 0.2 shrinks, 3 swells' }),
|
| 67 |
+
blend: { kind: 'word', words: 1, values: AURA_BLENDS, doc: 'add makes overlapping particles brighter, normal just stacks them' },
|
| 68 |
+
glowing: { kind: 'flag', words: 0, doc: 'soft glowing particles that add light' },
|
| 69 |
+
behind: { kind: 'flag', words: 0, doc: 'draw only outside the element\'s box, as if the element hid them' },
|
| 70 |
+
seed: num(0, 99999, { int: true, doc: 'a different random pattern with the same look' }),
|
| 71 |
+
texture: { kind: 'texture', words: 1, doc: 'texture(grain): grainy particles' },
|
| 72 |
+
essential: { kind: 'flag', words: 0, doc: 'keep animating even when the visitor prefers reduced motion' },
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
/* ------------------------------------------------------------------ *
|
| 76 |
+
* parsing (compile time, no DOM)
|
| 77 |
+
* ------------------------------------------------------------------ */
|
| 78 |
+
|
| 79 |
+
function splitCommas(s) {
|
| 80 |
+
const out = []; let depth = 0, cur = '';
|
| 81 |
+
for (const ch of s) {
|
| 82 |
+
if (ch === '(') depth++;
|
| 83 |
+
if (ch === ')') depth--;
|
| 84 |
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
| 85 |
+
cur += ch;
|
| 86 |
+
}
|
| 87 |
+
if (cur.trim()) out.push(cur.trim());
|
| 88 |
+
return out;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const isNumeric = (w) => w !== '' && !Number.isNaN(Number(w));
|
| 92 |
+
/** could this word be a colour? A bare unknown word (speedd) must NOT be swallowed as one, or the typo
|
| 93 |
+
* never gets its "did you mean" */
|
| 94 |
+
const looksLikeColor = (w) => /^#[0-9a-f]{3,8}(\/[\d.]+%?)?$/i.test(w) || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix|var)\(/i.test(w)
|
| 95 |
+
|| /^[a-z][\w-]*(\.[\w-]+)+(\/[\d.]+%?)?$/i.test(w) || /^(transparent|currentcolor)$/i.test(w) || parseColor(w) !== null;
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* Parse one layer's words.
|
| 99 |
+
* kind: 'glow' | 'particles' | 'both' which keys are legal
|
| 100 |
+
* forceGlowing: glow-particle
|
| 101 |
+
* Returns { glow?, particles? } layers, or null (an error was reported).
|
| 102 |
+
*/
|
| 103 |
+
function parseLayerWords(words, kind, hooks, opts = {}) {
|
| 104 |
+
const wantGlow = kind === 'glow' || kind === 'both';
|
| 105 |
+
const wantParticles = kind === 'particles' || kind === 'both';
|
| 106 |
+
const keys = kind === 'glow' ? GLOW_KEYS : kind === 'particles' ? PARTICLE_KEYS : { ...GLOW_KEYS, ...PARTICLE_KEYS };
|
| 107 |
+
const g = {}, p = {};
|
| 108 |
+
const setBoth = (k, v) => { if (k in GLOW_KEYS && wantGlow) g[k] = v; if (k in PARTICLE_KEYS && wantParticles) p[k] = v; };
|
| 109 |
+
const textureOf = (inner) => parseTexture(inner, hooks);
|
| 110 |
+
|
| 111 |
+
let i = 0;
|
| 112 |
+
const err = (m, h) => { hooks.error(m, h); return null; };
|
| 113 |
+
|
| 114 |
+
// positional shorthand: aura-glow: <color> <radius> aura-particle: <colors> <count>
|
| 115 |
+
const isKey = (w) => Object.prototype.hasOwnProperty.call(keys, w) || w in DIR_WORDS;
|
| 116 |
+
if (words.length && !isKey(words[0]) && words[0] !== '->') {
|
| 117 |
+
const stops = [];
|
| 118 |
+
while (i < words.length && !isKey(words[i]) && !isNumeric(words[i]) && (words[i] === '->' || looksLikeColor(words[i]))) {
|
| 119 |
+
if (words[i] === '->') { i++; continue; }
|
| 120 |
+
const css = hooks.color(words[i]);
|
| 121 |
+
if (css === null) return null;
|
| 122 |
+
stops.push(css);
|
| 123 |
+
i++;
|
| 124 |
+
}
|
| 125 |
+
if (stops.length) {
|
| 126 |
+
if (wantGlow) g.color = stops[0];
|
| 127 |
+
if (wantParticles) p.colors = stops;
|
| 128 |
+
}
|
| 129 |
+
if (i < words.length && isNumeric(words[i])) {
|
| 130 |
+
const n = Number(words[i]);
|
| 131 |
+
if (wantGlow && !wantParticles) g.radius = n;
|
| 132 |
+
else if (wantParticles && !wantGlow) p.count = n;
|
| 133 |
+
else { g.radius = n; }
|
| 134 |
+
i++;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
while (i < words.length) {
|
| 139 |
+
const w = words[i];
|
| 140 |
+
if (w in DIR_WORDS && !(w in keys)) { if (wantParticles) p.dir = DIR_WORDS[w]; i++; continue; }
|
| 141 |
+
// texture(grain scale 2) is one function-style word that carries its own settings
|
| 142 |
+
const fnTex = /^texture\((.*)\)$/s.exec(w);
|
| 143 |
+
if (fnTex && keys.texture) {
|
| 144 |
+
i++;
|
| 145 |
+
const t = textureOf(fnTex[1].trim());
|
| 146 |
+
if (t === null) return null;
|
| 147 |
+
setBoth('texture', t);
|
| 148 |
+
continue;
|
| 149 |
+
}
|
| 150 |
+
const def = keys[w];
|
| 151 |
+
if (!def) {
|
| 152 |
+
const all = [...Object.keys(keys), ...Object.keys(DIR_WORDS)];
|
| 153 |
+
return err(`unknown aura setting "${w}"`, hooks.suggest(w, all) || `settings: ${Object.keys(keys).join(', ')}`);
|
| 154 |
+
}
|
| 155 |
+
i++;
|
| 156 |
+
if (def.kind === 'flag') {
|
| 157 |
+
if (w === 'glowing') { if (wantParticles) p.glowing = true; }
|
| 158 |
+
else setBoth(w, true);
|
| 159 |
+
continue;
|
| 160 |
+
}
|
| 161 |
+
if (def.kind === 'colors') {
|
| 162 |
+
const stops = [];
|
| 163 |
+
while (i < words.length && (words[i] === '->' || (!isKey(words[i]) && !isNumeric(words[i]) && looksLikeColor(words[i])))) {
|
| 164 |
+
if (words[i] === '->') { i++; continue; }
|
| 165 |
+
const css = hooks.color(words[i]);
|
| 166 |
+
if (css === null) return null;
|
| 167 |
+
stops.push(css);
|
| 168 |
+
i++;
|
| 169 |
+
}
|
| 170 |
+
if (!stops.length) return err('color needs at least one colour', def.doc);
|
| 171 |
+
if (wantGlow && 'color' in GLOW_KEYS) g.color = stops[0];
|
| 172 |
+
if (wantParticles) p.colors = stops;
|
| 173 |
+
continue;
|
| 174 |
+
}
|
| 175 |
+
if (def.kind === 'color') {
|
| 176 |
+
const css = hooks.color(words[i]);
|
| 177 |
+
if (css === null) return null;
|
| 178 |
+
i++;
|
| 179 |
+
setBoth(w, css);
|
| 180 |
+
if (w === 'color' && wantParticles) p.colors = [css];
|
| 181 |
+
continue;
|
| 182 |
+
}
|
| 183 |
+
if (def.kind === 'texture') {
|
| 184 |
+
const word = words[i];
|
| 185 |
+
i++;
|
| 186 |
+
const m = /^texture\((.*)\)$/s.exec(word || '');
|
| 187 |
+
const inner = m ? m[1] : word;
|
| 188 |
+
if (!inner) return err('texture needs a name', def.doc);
|
| 189 |
+
const t = textureOf(inner);
|
| 190 |
+
if (t === null) return null;
|
| 191 |
+
setBoth('texture', t);
|
| 192 |
+
continue;
|
| 193 |
+
}
|
| 194 |
+
if (def.kind === 'dir') {
|
| 195 |
+
const v = words[i]; i++;
|
| 196 |
+
if (v in DIR_WORDS) p.dir = DIR_WORDS[v];
|
| 197 |
+
else if (isNumeric(v)) p.dir = Number(v);
|
| 198 |
+
else return err(`unknown direction "${v}"`, hooks.suggest(v, Object.keys(DIR_WORDS)) || 'up, down, left, right, out, in, or degrees');
|
| 199 |
+
continue;
|
| 200 |
+
}
|
| 201 |
+
if (def.kind === 'word') {
|
| 202 |
+
const v = words[i]; i++;
|
| 203 |
+
if (!def.values.includes(v)) return err(`unknown ${w} "${v}"`, hooks.suggest(v, def.values) || `use one of: ${def.values.join(', ')}`);
|
| 204 |
+
if (wantParticles) p[w] = v;
|
| 205 |
+
continue;
|
| 206 |
+
}
|
| 207 |
+
if (def.kind === 'range') {
|
| 208 |
+
const vals = [];
|
| 209 |
+
while (i < words.length && vals.length < 2 && isNumeric(words[i])) { vals.push(Number(words[i])); i++; }
|
| 210 |
+
if (!vals.length) return err(`${w} needs a number`, def.doc);
|
| 211 |
+
if (vals.some((n) => n < def.min || n > def.max)) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
|
| 212 |
+
if (wantParticles) p[w] = vals.length === 1 ? [vals[0], vals[0]] : [Math.min(...vals), Math.max(...vals)];
|
| 213 |
+
continue;
|
| 214 |
+
}
|
| 215 |
+
// plain number
|
| 216 |
+
const raw = words[i]; i++;
|
| 217 |
+
if (!isNumeric(raw)) return err(`${w} needs a number, got "${raw ?? ''}"`, def.doc);
|
| 218 |
+
const n = Number(raw);
|
| 219 |
+
if (n < def.min || n > def.max) return err(`${w} must be between ${def.min} and ${def.max}`, def.doc);
|
| 220 |
+
setBoth(w === 'turbulence' ? 'turbulence' : w, def.int ? Math.round(n) : n);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (opts.forceGlowing && wantParticles) p.glowing = true;
|
| 224 |
+
const out = {};
|
| 225 |
+
if (wantGlow && (kind === 'glow' || Object.keys(g).length || opts.requireGlow)) out.glow = { kind: 'glow', ...g };
|
| 226 |
+
if (wantParticles && (kind === 'particles' || Object.keys(p).length || opts.requireParticles)) out.particles = { kind: 'particles', ...p };
|
| 227 |
+
return out;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/**
|
| 231 |
+
* Parse the value of one of the aura properties. `prop` decides the shape:
|
| 232 |
+
* 'aura' layers need a leading glow / particles word
|
| 233 |
+
* 'aura-glow' one glow layer
|
| 234 |
+
* 'aura-particle' one particles layer
|
| 235 |
+
* 'glow-particle' one particles layer, glowing
|
| 236 |
+
* 'aura-glow-particle' a glow layer AND a glowing particles layer, settings shared
|
| 237 |
+
* Returns { glow: [layers]|null, particles: [layers]|null } where null means "clear this slot",
|
| 238 |
+
* or null when invalid.
|
| 239 |
+
*/
|
| 240 |
+
export function parseAura(prop, value, hooks) {
|
| 241 |
+
const text = String(value || '').trim();
|
| 242 |
+
if (!text) { hooks.error(`${prop} needs a value`, 'e.g. aura-glow: color.accent 24'); return null; }
|
| 243 |
+
const base = prop.replace(/s$/, ''); // aura-particles -> aura-particle
|
| 244 |
+
if (text === 'none') {
|
| 245 |
+
// `none` clears exactly the slots this property owns, so aura-glow: none leaves particles alone
|
| 246 |
+
const clears = { 'aura': { glow: null, particles: null }, 'aura-glow': { glow: null }, 'aura-particle': { particles: null }, 'glow-particle': { particles: null }, 'aura-glow-particle': { glow: null, particles: null } };
|
| 247 |
+
return clears[base] || { glow: null, particles: null };
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (prop === 'aura') {
|
| 251 |
+
const glow = [], particles = [];
|
| 252 |
+
for (const part of splitCommas(text)) {
|
| 253 |
+
const words = splitWords(part);
|
| 254 |
+
const head = words[0];
|
| 255 |
+
let kind;
|
| 256 |
+
if (head === 'glow') kind = 'glow';
|
| 257 |
+
else if (head === 'particles' || head === 'particle') kind = 'particles';
|
| 258 |
+
else {
|
| 259 |
+
hooks.error(`an aura layer starts with glow or particles, not "${head}"`, hooks.suggest(head, ['glow', 'particles']) || 'e.g. aura: glow color.accent 24, particles #fc4 count 40');
|
| 260 |
+
return null;
|
| 261 |
+
}
|
| 262 |
+
const r = parseLayerWords(words.slice(1), kind, hooks);
|
| 263 |
+
if (!r) return null;
|
| 264 |
+
if (r.glow) glow.push(r.glow);
|
| 265 |
+
if (r.particles) particles.push(r.particles);
|
| 266 |
+
}
|
| 267 |
+
return { glow, particles };
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
const out = { glow: undefined, particles: undefined };
|
| 271 |
+
const layersOf = (kind, opts) => {
|
| 272 |
+
const arr = [];
|
| 273 |
+
for (const part of splitCommas(text)) {
|
| 274 |
+
const r = parseLayerWords(splitWords(part), kind, hooks, opts);
|
| 275 |
+
if (!r) return null;
|
| 276 |
+
if (r.glow) arr.push(r.glow);
|
| 277 |
+
if (r.particles) arr.push(r.particles);
|
| 278 |
+
}
|
| 279 |
+
return arr;
|
| 280 |
+
};
|
| 281 |
+
|
| 282 |
+
if (base === 'aura-glow') { const a = layersOf('glow'); if (!a) return null; out.glow = a; }
|
| 283 |
+
else if (base === 'aura-particle') { const a = layersOf('particles'); if (!a) return null; out.particles = a; }
|
| 284 |
+
else if (base === 'glow-particle') { const a = layersOf('particles', { forceGlowing: true }); if (!a) return null; out.particles = a; }
|
| 285 |
+
else if (base === 'aura-glow-particle') {
|
| 286 |
+
const glow = [], particles = [];
|
| 287 |
+
for (const part of splitCommas(text)) {
|
| 288 |
+
const r = parseLayerWords(splitWords(part), 'both', hooks, { forceGlowing: true, requireGlow: true, requireParticles: true });
|
| 289 |
+
if (!r) return null;
|
| 290 |
+
if (r.glow) glow.push(r.glow);
|
| 291 |
+
if (r.particles) particles.push(r.particles);
|
| 292 |
+
}
|
| 293 |
+
out.glow = glow; out.particles = particles;
|
| 294 |
+
} else { hooks.error(`unknown aura property "${prop}"`, null); return null; }
|
| 295 |
+
return out;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
/* ------------------------------------------------------------------ *
|
| 299 |
+
* turning a parsed layer into numbers the runtime can use
|
| 300 |
+
* ------------------------------------------------------------------ */
|
| 301 |
+
|
| 302 |
+
export const GLOW_DEFAULTS = { radius: 24, intensity: 1, pulse: 0, depth: 0.35, flicker: 0, seed: 1 };
|
| 303 |
+
export const PARTICLE_DEFAULTS = {
|
| 304 |
+
count: 40, size: [4, 4], life: [900, 900], speed: [40, 40], dir: -90, spread: 30,
|
| 305 |
+
gravity: 0, drag: 0, turbulence: 0, from: 'edge', shape: 'circle', grow: 0.3, blend: null, glowing: false, seed: 1,
|
| 306 |
+
};
|
| 307 |
+
|
| 308 |
+
/**
|
| 309 |
+
* Resolve a parsed layer for drawing. `rc(cssColor)` returns {r,g,b,a} or null,
|
| 310 |
+
* `fallback` is the colour used when the layer names none (the element's text colour).
|
| 311 |
+
*/
|
| 312 |
+
export function resolveLayer(layer, rc, fallback) {
|
| 313 |
+
const pick = (css) => (css === undefined || /^currentcolor$/i.test(css) ? fallback : (rc(css) || fallback));
|
| 314 |
+
if (layer.kind === 'glow') {
|
| 315 |
+
return { ...GLOW_DEFAULTS, ...layer, rgba: pick(layer.color), tex: layer.texture && layer.texture.length ? layer.texture : null };
|
| 316 |
+
}
|
| 317 |
+
const stops = (layer.colors && layer.colors.length ? layer.colors : [undefined]).map(pick);
|
| 318 |
+
if (stops.length === 1) stops.push(withAlpha(stops[0], 0)); // a single colour fades out
|
| 319 |
+
const glowing = !!layer.glowing;
|
| 320 |
+
return {
|
| 321 |
+
...PARTICLE_DEFAULTS, ...layer, stops, glowing, behind: !!layer.behind,
|
| 322 |
+
blend: layer.blend || (glowing ? 'add' : 'normal'),
|
| 323 |
+
tex: layer.texture && layer.texture.length ? layer.texture : null,
|
| 324 |
+
};
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/* ====================================================================== *
|
| 328 |
+
* RUNTIME. Everything below needs a browser.
|
| 329 |
+
* ====================================================================== */
|
| 330 |
+
|
| 331 |
+
const TAU = Math.PI * 2;
|
| 332 |
+
const lerp = (a, b, t) => a + (b - a) * t;
|
| 333 |
+
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
| 334 |
+
|
| 335 |
+
function roundRectPath(ctx, x, y, w, h, r) {
|
| 336 |
+
r = Math.max(0, Math.min(r, w / 2, h / 2));
|
| 337 |
+
ctx.moveTo(x + r, y);
|
| 338 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 339 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 340 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 341 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 342 |
+
ctx.closePath();
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
function colorAt(stops, t) {
|
| 346 |
+
const n = stops.length - 1;
|
| 347 |
+
if (n <= 0) return stops[0];
|
| 348 |
+
const f = clamp01(t) * n;
|
| 349 |
+
const i = Math.min(n - 1, Math.floor(f));
|
| 350 |
+
const k = f - i;
|
| 351 |
+
const a = stops[i], b = stops[i + 1];
|
| 352 |
+
return { r: lerp(a.r, b.r, k), g: lerp(a.g, b.g, k), b: lerp(a.b, b.b, k), a: lerp(a.a, b.a, k) };
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/** A tiny signature, used to tell "same settings" from "changed" without deep compares */
|
| 356 |
+
const sigOf = (x) => JSON.stringify(x);
|
| 357 |
+
|
| 358 |
+
export class AuraManager {
|
| 359 |
+
/**
|
| 360 |
+
* opts.reducedMotion: a MediaQueryList (or anything with .matches)
|
| 361 |
+
* opts.budget: the most particles alive across the whole page
|
| 362 |
+
*/
|
| 363 |
+
constructor(opts = {}) {
|
| 364 |
+
this.reducedMotion = opts.reducedMotion || null;
|
| 365 |
+
this.budget = opts.budget || 1800;
|
| 366 |
+
this.emitters = new Map(); // element -> emitter
|
| 367 |
+
this.canvas = null;
|
| 368 |
+
this.ctx = null;
|
| 369 |
+
this.dpr = 1;
|
| 370 |
+
this.vw = 0; this.vh = 0;
|
| 371 |
+
this.running = false;
|
| 372 |
+
this.paused = false;
|
| 373 |
+
this.last = 0;
|
| 374 |
+
this.sprites = new Map();
|
| 375 |
+
this.masks = new Map();
|
| 376 |
+
this.io = null;
|
| 377 |
+
this.stats = { particles: 0, emitters: 0, frameMs: 0, fps: 0, frames: 0 };
|
| 378 |
+
this._fpsT = 0; this._fpsN = 0;
|
| 379 |
+
this._onResize = () => this.resize();
|
| 380 |
+
// reduced motion draws a still picture, so it only needs a new frame when something could have moved
|
| 381 |
+
this._onScroll = () => { if (this.reduced && !this.running && !this.paused && this.emitters.size) this.start(); };
|
| 382 |
+
this._poll = null;
|
| 383 |
+
this._frame = (t) => this.frame(t);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
get reduced() { return !!(this.reducedMotion && this.reducedMotion.matches); }
|
| 387 |
+
|
| 388 |
+
/* ---- setup ---- */
|
| 389 |
+
|
| 390 |
+
ensureCanvas() {
|
| 391 |
+
if (this.canvas) return;
|
| 392 |
+
const c = document.createElement('canvas');
|
| 393 |
+
c.setAttribute('aria-hidden', 'true');
|
| 394 |
+
c.setAttribute('data-dssl-aura', '');
|
| 395 |
+
c.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483000;';
|
| 396 |
+
(document.body || document.documentElement).appendChild(c);
|
| 397 |
+
this.canvas = c;
|
| 398 |
+
this.ctx = c.getContext('2d');
|
| 399 |
+
this.resize();
|
| 400 |
+
window.addEventListener('resize', this._onResize, { passive: true });
|
| 401 |
+
window.addEventListener('scroll', this._onScroll, { passive: true, capture: true });
|
| 402 |
+
if (typeof IntersectionObserver !== 'undefined') {
|
| 403 |
+
this.io = new IntersectionObserver((entries) => {
|
| 404 |
+
for (const e of entries) {
|
| 405 |
+
const em = this.emitters.get(e.target);
|
| 406 |
+
if (em) { em.visible = e.isIntersecting; if (e.isIntersecting) this.start(); }
|
| 407 |
+
}
|
| 408 |
+
}, { rootMargin: '200px' });
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
resize() {
|
| 413 |
+
if (!this.canvas) return;
|
| 414 |
+
this.dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 415 |
+
this.vw = window.innerWidth; this.vh = window.innerHeight;
|
| 416 |
+
this.canvas.width = Math.max(1, Math.round(this.vw * this.dpr));
|
| 417 |
+
this.canvas.height = Math.max(1, Math.round(this.vh * this.dpr));
|
| 418 |
+
this.dirty = true;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/* ---- the engine's interface ---- */
|
| 422 |
+
|
| 423 |
+
/** Set (or change) the aura of an element. glow/particles are arrays of resolved layers. */
|
| 424 |
+
set(el, glow, particles) {
|
| 425 |
+
const hasGlow = glow && glow.length, hasParts = particles && particles.length;
|
| 426 |
+
if (!hasGlow && !hasParts) { this.remove(el); return; }
|
| 427 |
+
this.ensureCanvas();
|
| 428 |
+
let em = this.emitters.get(el);
|
| 429 |
+
if (!em) {
|
| 430 |
+
em = { el, visible: true, rect: null, radius: 0, glow: [], layers: [], dying: false, fade: 1, sig: '', age: 0, radiusAt: -1 };
|
| 431 |
+
this.emitters.set(el, em);
|
| 432 |
+
if (this.io) this.io.observe(el);
|
| 433 |
+
}
|
| 434 |
+
em.dying = false;
|
| 435 |
+
const sig = sigOf([glow, particles]);
|
| 436 |
+
if (sig === em.sig) { this.start(); return; }
|
| 437 |
+
em.sig = sig;
|
| 438 |
+
em.glow = (glow || []).map((g, i) => ({ cfg: g, rnd: rng((g.seed || 1) * 7919 + i), flick: 0, flickTarget: 0, t0: performance.now() }));
|
| 439 |
+
// keep running particles when the layer list keeps its shape; otherwise start fresh
|
| 440 |
+
const old = em.layers;
|
| 441 |
+
em.layers = (particles || []).map((cfg, i) => {
|
| 442 |
+
const prev = old[i];
|
| 443 |
+
const keep = prev && prev.parts;
|
| 444 |
+
return { cfg, rnd: keep ? prev.rnd : rng((cfg.seed || 1) * 104729 + i), parts: keep ? prev.parts : [], acc: keep ? prev.acc : 0 };
|
| 445 |
+
});
|
| 446 |
+
this.start();
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
remove(el) {
|
| 450 |
+
const em = this.emitters.get(el);
|
| 451 |
+
if (!em) return;
|
| 452 |
+
em.dying = true; // stop emitting, let what is alive finish, fade the glow
|
| 453 |
+
this.start();
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
pause() { this.paused = true; this.running = false; if (this._poll) { clearTimeout(this._poll); this._poll = null; } if (this.ctx) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }
|
| 457 |
+
resume() { this.paused = false; this.start(); }
|
| 458 |
+
|
| 459 |
+
destroy() {
|
| 460 |
+
this.running = false;
|
| 461 |
+
window.removeEventListener('resize', this._onResize);
|
| 462 |
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
| 463 |
+
if (this._poll) { clearTimeout(this._poll); this._poll = null; }
|
| 464 |
+
if (this.io) this.io.disconnect();
|
| 465 |
+
if (this.canvas) this.canvas.remove();
|
| 466 |
+
this.canvas = null; this.ctx = null; this.emitters.clear();
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
/* ---- loop ---- */
|
| 470 |
+
|
| 471 |
+
start() {
|
| 472 |
+
if (this.running || this.paused || !this.canvas) return;
|
| 473 |
+
this.running = true;
|
| 474 |
+
this.last = 0;
|
| 475 |
+
requestAnimationFrame(this._frame);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
frame(now) {
|
| 479 |
+
if (!this.running) return;
|
| 480 |
+
const t0 = performance.now();
|
| 481 |
+
const dt = this.last ? Math.min(0.05, (now - this.last) / 1000) : 0.016;
|
| 482 |
+
this.last = now;
|
| 483 |
+
const reduced = this.reduced;
|
| 484 |
+
const ctx = this.ctx, dpr = this.dpr;
|
| 485 |
+
|
| 486 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 487 |
+
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
| 488 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 489 |
+
|
| 490 |
+
// global budget: if everyone asks for more than the page can afford, everyone gets a share
|
| 491 |
+
let want = 0;
|
| 492 |
+
for (const em of this.emitters.values()) if (em.visible && !em.dying) for (const l of em.layers) want += l.cfg.count;
|
| 493 |
+
const share = want > this.budget ? this.budget / want : 1;
|
| 494 |
+
|
| 495 |
+
let alive = 0, anyActive = false;
|
| 496 |
+
for (const [el, em] of this.emitters) {
|
| 497 |
+
if (!el.isConnected) { this.emitters.delete(el); if (this.io) this.io.unobserve(el); continue; }
|
| 498 |
+
// read geometry once per frame for visible emitters
|
| 499 |
+
let rect = null;
|
| 500 |
+
if (em.visible || em.layers.some((l) => l.parts.length)) {
|
| 501 |
+
rect = el.getBoundingClientRect();
|
| 502 |
+
if (rect.width === 0 && rect.height === 0) rect = null;
|
| 503 |
+
}
|
| 504 |
+
em.rect = rect;
|
| 505 |
+
if (em.radiusAt < 0 || (em.age++ % 45) === 0) { em.radius = this.cornerRadius(el, rect); em.radiusAt = 0; }
|
| 506 |
+
|
| 507 |
+
if (em.dying) em.fade = Math.max(0, em.fade - dt * 3);
|
| 508 |
+
else em.fade = Math.min(1, em.fade + dt * 4);
|
| 509 |
+
|
| 510 |
+
// glow
|
| 511 |
+
if (rect && em.fade > 0 && (em.visible || em.dying)) {
|
| 512 |
+
for (const g of em.glow) { this.drawGlow(ctx, rect, em, g, now, reduced); }
|
| 513 |
+
if (em.glow.length) anyActive = true;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
// particles
|
| 517 |
+
for (const L of em.layers) {
|
| 518 |
+
const cfg = L.cfg;
|
| 519 |
+
const moving = !reduced || cfg.essential;
|
| 520 |
+
if (rect && em.visible && !em.dying && moving) this.emit(L, rect, em, dt, share, el);
|
| 521 |
+
if (L.parts.length) this.stepAndDraw(ctx, L, dt, moving, cfg.behind && rect ? { rect, radius: em.radius } : null);
|
| 522 |
+
alive += L.parts.length;
|
| 523 |
+
if (L.parts.length || (rect && em.visible && !em.dying && moving)) anyActive = true;
|
| 524 |
+
}
|
| 525 |
+
if (em.dying && em.fade <= 0 && em.layers.every((l) => !l.parts.length)) {
|
| 526 |
+
this.emitters.delete(el);
|
| 527 |
+
if (this.io) this.io.unobserve(el);
|
| 528 |
+
}
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
this.stats.particles = alive;
|
| 532 |
+
this.stats.emitters = this.emitters.size;
|
| 533 |
+
this.stats.frameMs = performance.now() - t0;
|
| 534 |
+
this.stats.frames++;
|
| 535 |
+
this._fpsN++;
|
| 536 |
+
if (now - this._fpsT > 500) { this.stats.fps = Math.round((this._fpsN * 1000) / Math.max(1, now - this._fpsT)); this._fpsT = now; this._fpsN = 0; }
|
| 537 |
+
|
| 538 |
+
if (this.emitters.size === 0) { this.running = false; ctx.clearRect(0, 0, this.vw, this.vh); return; }
|
| 539 |
+
// Normally the loop runs every frame (the glow follows the element, particles move). With reduced
|
| 540 |
+
// motion nothing moves, so one frame is a complete picture; it is redrawn on scroll, resize, a
|
| 541 |
+
// style change, and a slow poll for layout shifts. Essential layers and fading-out emitters still loop.
|
| 542 |
+
let needLoop = !reduced;
|
| 543 |
+
if (reduced) {
|
| 544 |
+
for (const em of this.emitters.values()) {
|
| 545 |
+
if (em.dying) needLoop = true;
|
| 546 |
+
if (em.glow.some((g) => g.cfg.essential) || em.layers.some((l) => l.cfg.essential && (l.parts.length || (em.visible && !em.dying)))) needLoop = true;
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
if (needLoop) { requestAnimationFrame(this._frame); return; }
|
| 550 |
+
this.running = false;
|
| 551 |
+
if (!this._poll) this._poll = setTimeout(() => { this._poll = null; this.start(); }, 600);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
cornerRadius(el, rect) {
|
| 555 |
+
try {
|
| 556 |
+
const v = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
|
| 557 |
+
const r = rect ? Math.min(rect.width, rect.height) / 2 : 0;
|
| 558 |
+
return Math.min(v, r);
|
| 559 |
+
} catch { return 0; }
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/* ---- glow ---- */
|
| 563 |
+
|
| 564 |
+
drawGlow(ctx, rect, em, g, now, reduced) {
|
| 565 |
+
const cfg = g.cfg;
|
| 566 |
+
const moving = !reduced || cfg.essential;
|
| 567 |
+
let a = cfg.intensity * em.fade;
|
| 568 |
+
if (moving && cfg.pulse > 0) {
|
| 569 |
+
const ph = ((now - g.t0) / cfg.pulse) * TAU;
|
| 570 |
+
a *= 1 - cfg.depth * 0.5 * (1 + Math.sin(ph)) ;
|
| 571 |
+
}
|
| 572 |
+
if (moving && cfg.flicker > 0) {
|
| 573 |
+
// smooth random flicker: ease toward a new random target a few times a second
|
| 574 |
+
if (Math.random() < 0.18) g.flickTarget = g.rnd();
|
| 575 |
+
g.flick += (g.flickTarget - g.flick) * 0.35;
|
| 576 |
+
a *= 1 - cfg.flicker * g.flick * 0.9;
|
| 577 |
+
}
|
| 578 |
+
if (a <= 0.004) return;
|
| 579 |
+
const c = cfg.rgba;
|
| 580 |
+
const R = cfg.radius;
|
| 581 |
+
const dpr = this.dpr;
|
| 582 |
+
const x = rect.left, y = rect.top, w = rect.width, h = rect.height;
|
| 583 |
+
const pad = Math.ceil(R * 1.6);
|
| 584 |
+
const lw = Math.ceil(w + pad * 2), lh = Math.ceil(h + pad * 2);
|
| 585 |
+
if (lw * lh * dpr * dpr > 6e6) return; // a glow bitmap bigger than this would cost more than it is worth
|
| 586 |
+
|
| 587 |
+
// The blur is the expensive part, and pulsing and flickering only change how BRIGHT the shape is, not
|
| 588 |
+
// what it looks like. So the shape is painted once into this glow's own bitmap, and each frame just
|
| 589 |
+
// draws that bitmap at the current alpha. It repaints only when the shape really changes.
|
| 590 |
+
const key = `${lw}x${lh}|${dpr}|${c.r | 0},${c.g | 0},${c.b | 0},${c.a.toFixed(2)}|${R}|${em.radius}|${cfg.tex ? sigOf(cfg.tex[0]) : ''}`;
|
| 591 |
+
if (!g.bitmap || g.bitmapKey !== key) {
|
| 592 |
+
if (!g.bitmap) g.bitmap = document.createElement('canvas');
|
| 593 |
+
const off = g.bitmap;
|
| 594 |
+
off.width = Math.ceil(lw * dpr); off.height = Math.ceil(lh * dpr);
|
| 595 |
+
const o = off.getContext('2d');
|
| 596 |
+
o.save();
|
| 597 |
+
// clip so the light never paints over the element itself
|
| 598 |
+
o.beginPath();
|
| 599 |
+
o.rect(0, 0, off.width, off.height);
|
| 600 |
+
o.scale(dpr, dpr);
|
| 601 |
+
roundRectPath(o, pad, pad, w, h, em.radius);
|
| 602 |
+
o.setTransform(1, 0, 0, 1, 0, 0);
|
| 603 |
+
o.clip('evenodd');
|
| 604 |
+
const SH = 10000; // draw the shape far off-canvas and let only its blurred shadow land here
|
| 605 |
+
o.shadowOffsetX = SH * dpr;
|
| 606 |
+
o.fillStyle = '#000';
|
| 607 |
+
for (const [k, mul] of [[1, 0.8], [0.55, 0.45], [0.25, 0.4]]) {
|
| 608 |
+
o.shadowBlur = R * k * dpr;
|
| 609 |
+
o.shadowColor = `rgba(${c.r | 0},${c.g | 0},${c.b | 0},${clamp01(c.a * mul)})`;
|
| 610 |
+
o.beginPath();
|
| 611 |
+
o.scale(dpr, dpr);
|
| 612 |
+
roundRectPath(o, pad - SH, pad, w, h, em.radius);
|
| 613 |
+
o.setTransform(1, 0, 0, 1, 0, 0);
|
| 614 |
+
o.fill();
|
| 615 |
+
}
|
| 616 |
+
o.restore();
|
| 617 |
+
if (cfg.tex) {
|
| 618 |
+
const mask = this.maskCanvas(cfg.tex[0], 128);
|
| 619 |
+
o.save();
|
| 620 |
+
o.globalCompositeOperation = 'destination-in';
|
| 621 |
+
const pat = o.createPattern(mask, 'repeat');
|
| 622 |
+
if (pat) { o.fillStyle = pat; o.fillRect(0, 0, off.width, off.height); }
|
| 623 |
+
o.restore();
|
| 624 |
+
}
|
| 625 |
+
g.bitmapKey = key;
|
| 626 |
+
}
|
| 627 |
+
ctx.save();
|
| 628 |
+
ctx.globalCompositeOperation = 'lighter';
|
| 629 |
+
// intensity above 1 brightens by drawing the same light more than once
|
| 630 |
+
const passes = Math.max(1, Math.ceil(a));
|
| 631 |
+
ctx.globalAlpha = clamp01(a / passes);
|
| 632 |
+
for (let i = 0; i < passes; i++) ctx.drawImage(g.bitmap, 0, 0, g.bitmap.width, g.bitmap.height, x - pad, y - pad, lw, lh);
|
| 633 |
+
ctx.restore();
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
/* ---- masks and sprites ---- */
|
| 637 |
+
|
| 638 |
+
maskCanvas(texLayer, size) {
|
| 639 |
+
const key = sigOf([texLayer, size]);
|
| 640 |
+
let c = this.masks.get(key);
|
| 641 |
+
if (c) return c;
|
| 642 |
+
const data = makeMask(texLayer, size);
|
| 643 |
+
c = document.createElement('canvas');
|
| 644 |
+
c.width = c.height = size;
|
| 645 |
+
const x = c.getContext('2d');
|
| 646 |
+
const img = x.createImageData(size, size);
|
| 647 |
+
for (let i = 0; i < data.length; i++) { img.data[i * 4 + 3] = data[i]; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = 255; }
|
| 648 |
+
x.putImageData(img, 0, 0);
|
| 649 |
+
this.masks.set(key, c);
|
| 650 |
+
return c;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
/** a soft round sprite in one colour, optionally textured; cached by quantised colour */
|
| 654 |
+
sprite(c, shape, tex, variant) {
|
| 655 |
+
const q = (v) => (Math.round(v / 16) * 16) | 0;
|
| 656 |
+
const key = `${q(c.r)},${q(c.g)},${q(c.b)}|${shape}|${tex ? sigOf(tex[0]) : ''}|${variant}`;
|
| 657 |
+
let s = this.sprites.get(key);
|
| 658 |
+
if (s) return s;
|
| 659 |
+
if (this.sprites.size > 600) this.sprites.clear();
|
| 660 |
+
const S = 64;
|
| 661 |
+
s = document.createElement('canvas');
|
| 662 |
+
s.width = s.height = S;
|
| 663 |
+
const x = s.getContext('2d');
|
| 664 |
+
const rgb = `${q(c.r)},${q(c.g)},${q(c.b)}`;
|
| 665 |
+
if (shape === 'ring') {
|
| 666 |
+
x.strokeStyle = `rgb(${rgb})`; x.lineWidth = 5; x.beginPath(); x.arc(S / 2, S / 2, S / 2 - 6, 0, TAU); x.stroke();
|
| 667 |
+
} else if (shape === 'star') {
|
| 668 |
+
x.fillStyle = `rgb(${rgb})`; x.beginPath();
|
| 669 |
+
for (let i = 0; i < 8; i++) { const r = i % 2 ? S * 0.1 : S * 0.48, a = (i / 8) * TAU - Math.PI / 2; x.lineTo(S / 2 + Math.cos(a) * r, S / 2 + Math.sin(a) * r); }
|
| 670 |
+
x.closePath(); x.fill();
|
| 671 |
+
} else if (shape === 'square') {
|
| 672 |
+
x.fillStyle = `rgb(${rgb})`; x.fillRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7);
|
| 673 |
+
} else {
|
| 674 |
+
const g = x.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
| 675 |
+
const hard = shape === 'circle' ? 0.55 : 0.0;
|
| 676 |
+
g.addColorStop(0, `rgba(${rgb},1)`);
|
| 677 |
+
g.addColorStop(hard, `rgba(${rgb},${shape === 'circle' ? 0.95 : 0.7})`);
|
| 678 |
+
g.addColorStop(1, `rgba(${rgb},0)`);
|
| 679 |
+
x.fillStyle = g; x.fillRect(0, 0, S, S);
|
| 680 |
+
}
|
| 681 |
+
if (tex) {
|
| 682 |
+
// multiply the sprite's alpha by a texture mask, a different offset per variant
|
| 683 |
+
const m = this.maskCanvas(tex[0], 64);
|
| 684 |
+
const id = x.getImageData(0, 0, S, S);
|
| 685 |
+
const mx = m.getContext('2d').getImageData(0, 0, S, S).data;
|
| 686 |
+
const off = (variant * 13) % S;
|
| 687 |
+
for (let yy = 0; yy < S; yy++) for (let xx = 0; xx < S; xx++) {
|
| 688 |
+
const mi = (((yy + off) % S) * S + ((xx + off * 2) % S)) * 4 + 3;
|
| 689 |
+
const i = (yy * S + xx) * 4 + 3;
|
| 690 |
+
id.data[i] = (id.data[i] * mx[mi]) / 255;
|
| 691 |
+
}
|
| 692 |
+
x.putImageData(id, 0, 0);
|
| 693 |
+
}
|
| 694 |
+
this.sprites.set(key, s);
|
| 695 |
+
return s;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
/* ---- particles ---- */
|
| 699 |
+
|
| 700 |
+
emit(L, rect, em, dt, share, el) {
|
| 701 |
+
const c = L.cfg;
|
| 702 |
+
const meanLife = ((c.life[0] + c.life[1]) / 2) / 1000;
|
| 703 |
+
const rate = (c.count * share) / meanLife; // per second
|
| 704 |
+
L.acc += rate * dt;
|
| 705 |
+
const cap = Math.max(1, Math.round(c.count * share));
|
| 706 |
+
while (L.acc >= 1) {
|
| 707 |
+
L.acc -= 1;
|
| 708 |
+
if (L.parts.length >= cap) { L.acc = Math.min(L.acc, 1); break; }
|
| 709 |
+
L.parts.push(this.spawn(L, rect, el));
|
| 710 |
+
}
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
spawn(L, rect, el) {
|
| 714 |
+
const c = L.cfg, r = L.rnd;
|
| 715 |
+
const between = (a, b) => a + (b - a) * r();
|
| 716 |
+
let x, y;
|
| 717 |
+
switch (c.from) {
|
| 718 |
+
case 'bottom': x = rect.left + r() * rect.width; y = rect.bottom; break;
|
| 719 |
+
case 'top': x = rect.left + r() * rect.width; y = rect.top; break;
|
| 720 |
+
case 'left': x = rect.left; y = rect.top + r() * rect.height; break;
|
| 721 |
+
case 'right': x = rect.right; y = rect.top + r() * rect.height; break;
|
| 722 |
+
case 'center': x = rect.left + r() * rect.width; y = rect.top + r() * rect.height; break;
|
| 723 |
+
case 'corners': { const k = (r() * 4) | 0; x = k & 1 ? rect.right : rect.left; y = k & 2 ? rect.bottom : rect.top; break; }
|
| 724 |
+
case 'thumb': {
|
| 725 |
+
// the thumb of a range input: where the value is
|
| 726 |
+
let p = 0.5;
|
| 727 |
+
try { const mn = Number(el.min || 0), mx = Number(el.max || 100); p = (Number(el.value) - mn) / (mx - mn || 1); } catch { /* not a range */ }
|
| 728 |
+
const pad = 10;
|
| 729 |
+
x = rect.left + pad + p * Math.max(0, rect.width - pad * 2); y = rect.top + rect.height / 2 + (r() - 0.5) * 6; break;
|
| 730 |
+
}
|
| 731 |
+
default: { // edge: a random point on the perimeter
|
| 732 |
+
const per = 2 * (rect.width + rect.height);
|
| 733 |
+
let d = r() * per;
|
| 734 |
+
if (d < rect.width) { x = rect.left + d; y = rect.top; }
|
| 735 |
+
else if ((d -= rect.width) < rect.height) { x = rect.right; y = rect.top + d; }
|
| 736 |
+
else if ((d -= rect.height) < rect.width) { x = rect.right - d; y = rect.bottom; }
|
| 737 |
+
else { d -= rect.width; x = rect.left; y = rect.bottom - d; }
|
| 738 |
+
}
|
| 739 |
+
}
|
| 740 |
+
let ang;
|
| 741 |
+
if (c.dir === 'out' || c.dir === 'in') {
|
| 742 |
+
const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
|
| 743 |
+
ang = Math.atan2(y - cy, x - cx);
|
| 744 |
+
if (c.dir === 'in') ang += Math.PI;
|
| 745 |
+
} else ang = (c.dir * Math.PI) / 180;
|
| 746 |
+
ang += ((r() - 0.5) * c.spread * Math.PI) / 180;
|
| 747 |
+
const sp = between(c.speed[0], c.speed[1]);
|
| 748 |
+
return {
|
| 749 |
+
x, y, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp,
|
| 750 |
+
age: 0, life: between(c.life[0], c.life[1]) / 1000,
|
| 751 |
+
size: between(c.size[0], c.size[1]), rot: r() * TAU, spin: (r() - 0.5) * 4, variant: (r() * 4) | 0, ph: r() * TAU,
|
| 752 |
+
};
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
stepAndDraw(ctx, L, dt, moving, hide) {
|
| 756 |
+
const c = L.cfg, parts = L.parts;
|
| 757 |
+
const additive = c.blend === 'add';
|
| 758 |
+
ctx.save();
|
| 759 |
+
if (hide) {
|
| 760 |
+
// clip the element's own box OUT, so the layer reads as sitting behind it
|
| 761 |
+
ctx.beginPath();
|
| 762 |
+
ctx.rect(-50, -50, this.vw + 100, this.vh + 100);
|
| 763 |
+
roundRectPath(ctx, hide.rect.left, hide.rect.top, hide.rect.width, hide.rect.height, hide.radius);
|
| 764 |
+
ctx.clip('evenodd');
|
| 765 |
+
}
|
| 766 |
+
ctx.globalCompositeOperation = additive ? 'lighter' : 'source-over';
|
| 767 |
+
const drag = Math.exp(-c.drag * dt);
|
| 768 |
+
for (let i = parts.length - 1; i >= 0; i--) {
|
| 769 |
+
const p = parts[i];
|
| 770 |
+
if (moving) {
|
| 771 |
+
p.age += dt;
|
| 772 |
+
if (p.age >= p.life) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
|
| 773 |
+
p.vy += c.gravity * dt;
|
| 774 |
+
if (c.turbulence) { p.vx += Math.sin(p.ph + p.age * 7) * c.turbulence * dt; p.vy += Math.cos(p.ph * 1.3 + p.age * 5) * c.turbulence * 0.3 * dt; }
|
| 775 |
+
if (c.drag) { p.vx *= drag; p.vy *= drag; }
|
| 776 |
+
p.x += p.vx * dt; p.y += p.vy * dt;
|
| 777 |
+
p.rot += p.spin * dt;
|
| 778 |
+
}
|
| 779 |
+
const t = p.age / p.life;
|
| 780 |
+
const col = colorAt(c.stops, t);
|
| 781 |
+
const alpha = clamp01(col.a);
|
| 782 |
+
if (alpha <= 0.003) continue;
|
| 783 |
+
const size = p.size * lerp(1, c.grow, t);
|
| 784 |
+
this.drawParticle(ctx, c, p, col, alpha, size);
|
| 785 |
+
}
|
| 786 |
+
ctx.restore();
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
drawParticle(ctx, c, p, col, alpha, size) {
|
| 790 |
+
const shape = c.shape;
|
| 791 |
+
if (shape === 'spark' || shape === 'line') {
|
| 792 |
+
const len = Math.max(size, Math.hypot(p.vx, p.vy) * 0.06);
|
| 793 |
+
const sp = Math.hypot(p.vx, p.vy) || 1;
|
| 794 |
+
ctx.strokeStyle = `rgba(${col.r | 0},${col.g | 0},${col.b | 0},${alpha})`;
|
| 795 |
+
ctx.lineWidth = Math.max(1, size * 0.3);
|
| 796 |
+
ctx.lineCap = 'round';
|
| 797 |
+
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - (p.vx / sp) * len, p.y - (p.vy / sp) * len); ctx.stroke();
|
| 798 |
+
if (c.glowing) {
|
| 799 |
+
const s = this.sprite(col, 'glow', c.tex, p.variant), d = size * 3;
|
| 800 |
+
ctx.globalAlpha = alpha * 0.5; ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d); ctx.globalAlpha = 1;
|
| 801 |
+
}
|
| 802 |
+
return;
|
| 803 |
+
}
|
| 804 |
+
const kind = shape === 'circle' && c.glowing ? 'glow' : shape === 'flame' ? 'glow' : shape === 'smoke' ? 'glow' : shape;
|
| 805 |
+
const s = this.sprite(col, kind, c.tex, p.variant);
|
| 806 |
+
ctx.globalAlpha = alpha;
|
| 807 |
+
if (shape === 'flame') {
|
| 808 |
+
// a teardrop: stretched along its travel, narrower at the tip
|
| 809 |
+
const sp = Math.hypot(p.vx, p.vy) || 1;
|
| 810 |
+
const ang = Math.atan2(p.vy, p.vx) + Math.PI / 2;
|
| 811 |
+
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(ang);
|
| 812 |
+
const w = size * 2.2, h = size * 3.4;
|
| 813 |
+
ctx.drawImage(s, -w / 2, -h * 0.65, w, h);
|
| 814 |
+
ctx.restore();
|
| 815 |
+
} else if (shape === 'ring' || shape === 'star' || shape === 'square') {
|
| 816 |
+
ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
|
| 817 |
+
const d = size * (shape === 'ring' ? 1.2 : 1.4);
|
| 818 |
+
ctx.drawImage(s, -d / 2, -d / 2, d, d);
|
| 819 |
+
ctx.restore();
|
| 820 |
+
} else {
|
| 821 |
+
const d = size * (c.glowing || shape === 'smoke' ? 2.6 : 1.2);
|
| 822 |
+
ctx.drawImage(s, p.x - d / 2, p.y - d / 2, d, d);
|
| 823 |
+
}
|
| 824 |
+
ctx.globalAlpha = 1;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
/** for tests and tools: a readable snapshot, never needed in normal use */
|
| 828 |
+
debug() {
|
| 829 |
+
const out = [];
|
| 830 |
+
for (const [el, em] of this.emitters) out.push({ el, visible: em.visible, dying: em.dying, glow: em.glow.length, layers: em.layers.map((l) => ({ alive: l.parts.length, sample: l.parts.slice(0, 5).map((p) => ({ x: p.x, y: p.y, vx: p.vx, vy: p.vy, age: p.age })) })) });
|
| 831 |
+
return { stats: { ...this.stats }, running: this.running, paused: this.paused, canvas: !!this.canvas, emitters: out };
|
| 832 |
+
}
|
| 833 |
+
}
|
dssl-vscode/lib/engine/color.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: color.js
|
| 3 |
+
*
|
| 4 |
+
* One small colour library for everything that has to know what a colour
|
| 5 |
+
* actually IS: the contrast lint, aura particles painted on a canvas, texture
|
| 6 |
+
* tints baked into SVG. No dependencies, no DOM.
|
| 7 |
+
*
|
| 8 |
+
* Colours are { r, g, b, a } with r/g/b in 0..255 and a in 0..1.
|
| 9 |
+
*
|
| 10 |
+
* parseColor handles what the browser hands back as well as what people
|
| 11 |
+
* write: #rgb #rgba #rrggbb #rrggbbaa, rgb()/rgba(), hsl()/hsla(), oklab(),
|
| 12 |
+
* oklch(), color(srgb ...), color-mix(in <space> A p%, B), the CSS names, and
|
| 13 |
+
* transparent. Anything it cannot read returns null, and callers decide
|
| 14 |
+
* whether that is an error (a texture colour) or silence (a lint check).
|
| 15 |
+
* ========================================================================= */
|
| 16 |
+
|
| 17 |
+
const NAMED = {
|
| 18 |
+
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
| 19 |
+
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
| 20 |
+
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', cyan: '#00ffff', darkblue: '#00008b',
|
| 21 |
+
darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', darkmagenta: '#8b008b',
|
| 22 |
+
darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
|
| 23 |
+
darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3', deeppink: '#ff1493',
|
| 24 |
+
deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969', dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
| 25 |
+
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080', green: '#008000',
|
| 26 |
+
greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c',
|
| 27 |
+
lavender: '#e6e6fa', lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
|
| 28 |
+
lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1',
|
| 29 |
+
lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de',
|
| 30 |
+
lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
| 31 |
+
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a',
|
| 32 |
+
mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
| 33 |
+
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500',
|
| 34 |
+
orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093', papayawhip: '#ffefd5',
|
| 35 |
+
peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', red: '#ff0000',
|
| 36 |
+
rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee',
|
| 37 |
+
sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
|
| 38 |
+
springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee',
|
| 39 |
+
wheat: '#f5deb3', white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
export const TRANSPARENT = { r: 0, g: 0, b: 0, a: 0 };
|
| 43 |
+
|
| 44 |
+
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
| 45 |
+
const round = (x) => Math.round(x);
|
| 46 |
+
|
| 47 |
+
/* ---------- colour-space maths ---------- */
|
| 48 |
+
|
| 49 |
+
const srgbToLinear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
| 50 |
+
const linearToSrgb = (c) => { const v = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; return clamp(v, 0, 1) * 255; };
|
| 51 |
+
|
| 52 |
+
function oklabToSrgb(L, a, b) {
|
| 53 |
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
| 54 |
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
| 55 |
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
| 56 |
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
| 57 |
+
return [
|
| 58 |
+
linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
| 59 |
+
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
| 60 |
+
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s),
|
| 61 |
+
];
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function srgbToOklab(r, g, b) {
|
| 65 |
+
const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
|
| 66 |
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
| 67 |
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
| 68 |
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
| 69 |
+
return [
|
| 70 |
+
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
| 71 |
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
| 72 |
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
| 73 |
+
];
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function hslToRgb(h, s, l) {
|
| 77 |
+
h = ((h % 360) + 360) % 360; s = clamp(s, 0, 1); l = clamp(l, 0, 1);
|
| 78 |
+
const k = (n) => (n + h / 30) % 12;
|
| 79 |
+
const a = s * Math.min(l, 1 - l);
|
| 80 |
+
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
| 81 |
+
return [f(0) * 255, f(8) * 255, f(4) * 255];
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* ---------- parsing ---------- */
|
| 85 |
+
|
| 86 |
+
/** a number or a percentage; `scale` is what 100% means (255 for rgb, 1 for alpha...) */
|
| 87 |
+
function num(tok, scale = 1) {
|
| 88 |
+
if (tok === undefined || tok === null) return null;
|
| 89 |
+
const t = String(tok).trim();
|
| 90 |
+
if (t === 'none') return 0;
|
| 91 |
+
if (t.endsWith('%')) { const n = parseFloat(t); return Number.isNaN(n) ? null : (n / 100) * scale; }
|
| 92 |
+
const n = parseFloat(t);
|
| 93 |
+
return Number.isNaN(n) ? null : n;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function angle(tok) {
|
| 97 |
+
const t = String(tok).trim();
|
| 98 |
+
if (t === 'none') return 0;
|
| 99 |
+
const n = parseFloat(t);
|
| 100 |
+
if (Number.isNaN(n)) return null;
|
| 101 |
+
if (t.endsWith('turn')) return n * 360;
|
| 102 |
+
if (t.endsWith('rad')) return (n * 180) / Math.PI;
|
| 103 |
+
if (t.endsWith('grad')) return n * 0.9;
|
| 104 |
+
return n;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** split "a b c / d" or "a, b, c, d" function arguments at the top level */
|
| 108 |
+
function splitArgs(body) {
|
| 109 |
+
const out = [];
|
| 110 |
+
let depth = 0, cur = '';
|
| 111 |
+
for (const ch of body) {
|
| 112 |
+
if (ch === '(') depth++;
|
| 113 |
+
if (ch === ')') depth--;
|
| 114 |
+
if (depth === 0 && (ch === ',' || ch === '/' || /\s/.test(ch))) {
|
| 115 |
+
if (cur) out.push(cur);
|
| 116 |
+
cur = '';
|
| 117 |
+
if (ch === '/') out.push('/');
|
| 118 |
+
continue;
|
| 119 |
+
}
|
| 120 |
+
cur += ch;
|
| 121 |
+
}
|
| 122 |
+
if (cur) out.push(cur);
|
| 123 |
+
return out;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/** parse any one CSS colour. Returns {r,g,b,a} or null. */
|
| 127 |
+
export function parseColor(input) {
|
| 128 |
+
if (input === null || input === undefined) return null;
|
| 129 |
+
let s = String(input).trim().toLowerCase();
|
| 130 |
+
if (!s) return null;
|
| 131 |
+
if (s === 'transparent') return { ...TRANSPARENT };
|
| 132 |
+
if (NAMED[s]) s = NAMED[s];
|
| 133 |
+
|
| 134 |
+
if (s[0] === '#') {
|
| 135 |
+
const h = s.slice(1);
|
| 136 |
+
if (!/^[0-9a-f]+$/.test(h)) return null;
|
| 137 |
+
if (h.length === 3 || h.length === 4) {
|
| 138 |
+
const [r, g, b, a] = [...h].map((c) => parseInt(c + c, 16));
|
| 139 |
+
return { r, g, b, a: h.length === 4 ? a / 255 : 1 };
|
| 140 |
+
}
|
| 141 |
+
if (h.length === 6 || h.length === 8) {
|
| 142 |
+
return {
|
| 143 |
+
r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16),
|
| 144 |
+
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
| 145 |
+
};
|
| 146 |
+
}
|
| 147 |
+
return null;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const fn = /^([a-z-]+)\((.*)\)$/s.exec(s);
|
| 151 |
+
if (!fn) return null;
|
| 152 |
+
const name = fn[1];
|
| 153 |
+
const body = fn[2];
|
| 154 |
+
|
| 155 |
+
if (name === 'color-mix') return parseColorMix(body);
|
| 156 |
+
|
| 157 |
+
const parts = splitArgs(body);
|
| 158 |
+
const slash = parts.indexOf('/');
|
| 159 |
+
const main = slash === -1 ? parts : parts.slice(0, slash);
|
| 160 |
+
let alpha = 1;
|
| 161 |
+
if (slash !== -1) { alpha = num(parts[slash + 1], 1); if (alpha === null) return null; }
|
| 162 |
+
else if ((name === 'rgba' || name === 'hsla') && main.length === 4) alpha = num(main.pop(), 1);
|
| 163 |
+
else if ((name === 'rgb' || name === 'hsl') && main.length === 4) alpha = num(main.pop(), 1);
|
| 164 |
+
alpha = clamp(alpha, 0, 1);
|
| 165 |
+
|
| 166 |
+
if (name === 'rgb' || name === 'rgba') {
|
| 167 |
+
if (main.length !== 3) return null;
|
| 168 |
+
const [r, g, b] = main.map((t) => num(t, 255));
|
| 169 |
+
if ([r, g, b].some((v) => v === null)) return null;
|
| 170 |
+
return { r: clamp(round(r), 0, 255), g: clamp(round(g), 0, 255), b: clamp(round(b), 0, 255), a: alpha };
|
| 171 |
+
}
|
| 172 |
+
if (name === 'hsl' || name === 'hsla') {
|
| 173 |
+
if (main.length !== 3) return null;
|
| 174 |
+
const h = angle(main[0]), sat = num(main[1], 1), l = num(main[2], 1);
|
| 175 |
+
if ([h, sat, l].some((v) => v === null)) return null;
|
| 176 |
+
const [r, g, b] = hslToRgb(h, sat, l);
|
| 177 |
+
return { r: round(r), g: round(g), b: round(b), a: alpha };
|
| 178 |
+
}
|
| 179 |
+
if (name === 'oklab') {
|
| 180 |
+
if (main.length !== 3) return null;
|
| 181 |
+
const L = num(main[0], 1), A = num(main[1], 0.4), B = num(main[2], 0.4);
|
| 182 |
+
if ([L, A, B].some((v) => v === null)) return null;
|
| 183 |
+
const [r, g, b] = oklabToSrgb(L, A, B);
|
| 184 |
+
return { r: round(r), g: round(g), b: round(b), a: alpha };
|
| 185 |
+
}
|
| 186 |
+
if (name === 'oklch') {
|
| 187 |
+
if (main.length !== 3) return null;
|
| 188 |
+
const L = num(main[0], 1), C = num(main[1], 0.4), H = angle(main[2]);
|
| 189 |
+
if ([L, C, H].some((v) => v === null)) return null;
|
| 190 |
+
const rad = (H * Math.PI) / 180;
|
| 191 |
+
const [r, g, b] = oklabToSrgb(L, C * Math.cos(rad), C * Math.sin(rad));
|
| 192 |
+
return { r: round(r), g: round(g), b: round(b), a: alpha };
|
| 193 |
+
}
|
| 194 |
+
if (name === 'color') {
|
| 195 |
+
// what getComputedStyle returns for colour-mix and wide-gamut colours
|
| 196 |
+
const space = main[0];
|
| 197 |
+
if (space !== 'srgb' && space !== 'srgb-linear' && space !== 'display-p3') return null;
|
| 198 |
+
const [r, g, b] = main.slice(1, 4).map((t) => num(t, 1));
|
| 199 |
+
if ([r, g, b].some((v) => v === null)) return null;
|
| 200 |
+
if (space === 'srgb-linear') return { r: round(linearToSrgb(r)), g: round(linearToSrgb(g)), b: round(linearToSrgb(b)), a: alpha };
|
| 201 |
+
// display-p3 is wider than sRGB; clipping to sRGB is fine for canvas and contrast purposes
|
| 202 |
+
return { r: round(clamp(r, 0, 1) * 255), g: round(clamp(g, 0, 1) * 255), b: round(clamp(b, 0, 1) * 255), a: alpha };
|
| 203 |
+
}
|
| 204 |
+
return null;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/** color-mix(in oklab, red 30%, blue) / (in srgb, ...) , good enough for what DSSL emits */
|
| 208 |
+
function parseColorMix(body) {
|
| 209 |
+
const parts = splitTopLevelCommas(body);
|
| 210 |
+
if (parts.length < 3) return null;
|
| 211 |
+
const spaceM = /^in\s+([a-z-]+)/.exec(parts[0].trim());
|
| 212 |
+
if (!spaceM) return null;
|
| 213 |
+
const space = spaceM[1];
|
| 214 |
+
const one = (txt) => {
|
| 215 |
+
const m = /^(.*?)(?:\s+([\d.]+)%)?$/s.exec(txt.trim());
|
| 216 |
+
const color = parseColor(m[1].trim());
|
| 217 |
+
return color ? { color, pct: m[2] === undefined ? null : parseFloat(m[2]) } : null;
|
| 218 |
+
};
|
| 219 |
+
const A = one(parts[1]), B = one(parts[2]);
|
| 220 |
+
if (!A || !B) return null;
|
| 221 |
+
let pa = A.pct, pb = B.pct;
|
| 222 |
+
if (pa === null && pb === null) { pa = 50; pb = 50; }
|
| 223 |
+
else if (pa === null) pa = 100 - pb;
|
| 224 |
+
else if (pb === null) pb = 100 - pa;
|
| 225 |
+
const total = pa + pb;
|
| 226 |
+
if (total <= 0) return null;
|
| 227 |
+
const t = pb / total; // share of B
|
| 228 |
+
const alphaMul = Math.min(1, total / 100);
|
| 229 |
+
const mixed = mix(A.color, B.color, t, space === 'srgb' ? 'srgb' : 'oklab');
|
| 230 |
+
return { ...mixed, a: mixed.a * alphaMul };
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function splitTopLevelCommas(body) {
|
| 234 |
+
const out = [];
|
| 235 |
+
let depth = 0, cur = '';
|
| 236 |
+
for (const ch of body) {
|
| 237 |
+
if (ch === '(') depth++;
|
| 238 |
+
if (ch === ')') depth--;
|
| 239 |
+
if (depth === 0 && ch === ',') { out.push(cur); cur = ''; continue; }
|
| 240 |
+
cur += ch;
|
| 241 |
+
}
|
| 242 |
+
if (cur.trim()) out.push(cur);
|
| 243 |
+
return out;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/* ---------- operations ---------- */
|
| 247 |
+
|
| 248 |
+
/** premultiplied mix of two colours; t=0 is a, t=1 is b */
|
| 249 |
+
export function mix(a, b, t, space = 'oklab') {
|
| 250 |
+
t = clamp(t, 0, 1);
|
| 251 |
+
const alpha = a.a * (1 - t) + b.a * t;
|
| 252 |
+
if (alpha <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
| 253 |
+
const wa = (a.a * (1 - t)) / alpha, wb = (b.a * t) / alpha;
|
| 254 |
+
if (space === 'srgb') {
|
| 255 |
+
return { r: round(a.r * wa + b.r * wb), g: round(a.g * wa + b.g * wb), b: round(a.b * wa + b.b * wb), a: alpha };
|
| 256 |
+
}
|
| 257 |
+
const A = srgbToOklab(a.r, a.g, a.b), B = srgbToOklab(b.r, b.g, b.b);
|
| 258 |
+
const [r, g, bl] = oklabToSrgb(A[0] * wa + B[0] * wb, A[1] * wa + B[1] * wb, A[2] * wa + B[2] * wb);
|
| 259 |
+
return { r: round(r), g: round(g), b: round(bl), a: alpha };
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/** paint `fg` over an opaque-or-not `bg` */
|
| 263 |
+
export function over(fg, bg) {
|
| 264 |
+
const a = fg.a + bg.a * (1 - fg.a);
|
| 265 |
+
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
| 266 |
+
const f = (x, y) => round((x * fg.a + y * bg.a * (1 - fg.a)) / a);
|
| 267 |
+
return { r: f(fg.r, bg.r), g: f(fg.g, bg.g), b: f(fg.b, bg.b), a };
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
export const withAlpha = (c, a) => ({ ...c, a: clamp(a, 0, 1) });
|
| 271 |
+
|
| 272 |
+
/** WCAG relative luminance */
|
| 273 |
+
export function luminance(c) {
|
| 274 |
+
return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/** WCAG contrast ratio between two colours (alpha must already be composited) */
|
| 278 |
+
export function contrast(a, b) {
|
| 279 |
+
const L1 = luminance(a), L2 = luminance(b);
|
| 280 |
+
return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
export const toRgba = (c) => `rgba(${c.r}, ${c.g}, ${c.b}, ${+c.a.toFixed(3)})`;
|
| 284 |
+
export const toHex = (c) => '#' + [c.r, c.g, c.b].map((v) => round(v).toString(16).padStart(2, '0')).join('');
|
| 285 |
+
|
| 286 |
+
/**
|
| 287 |
+
* Evaluate a colour EXPRESSION as DSSL writes it, using a token lookup:
|
| 288 |
+
* color.ink a token (its value may itself be an expression)
|
| 289 |
+
* color.ink/40 a token with alpha
|
| 290 |
+
* #d1006f/50 a literal with alpha
|
| 291 |
+
* oklch(62% 0.19 35) anything parseColor reads
|
| 292 |
+
* `lookup(name)` returns the raw token string or undefined. Returns a colour
|
| 293 |
+
* or null when it cannot be known at compile time (a runtime-only var()).
|
| 294 |
+
*/
|
| 295 |
+
export function evalColorExpr(expr, lookup, depth = 0) {
|
| 296 |
+
if (depth > 8 || expr === null || expr === undefined) return null;
|
| 297 |
+
const s = String(expr).trim();
|
| 298 |
+
if (!s) return null;
|
| 299 |
+
const direct = parseColor(s);
|
| 300 |
+
if (direct) return direct;
|
| 301 |
+
const m = /^(.*?)\/\s*([\d.]+%?)$/.exec(s);
|
| 302 |
+
if (m && !s.includes('(')) {
|
| 303 |
+
const base = evalColorExpr(m[1].trim(), lookup, depth + 1);
|
| 304 |
+
if (!base) return null;
|
| 305 |
+
const a = num(m[2], 1);
|
| 306 |
+
if (a === null) return null;
|
| 307 |
+
return withAlpha(base, (a > 1 && !m[2].endsWith('%') ? a / 100 : a) * base.a);
|
| 308 |
+
}
|
| 309 |
+
if (/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/i.test(s) && lookup) {
|
| 310 |
+
const raw = lookup(s);
|
| 311 |
+
return raw === undefined ? null : evalColorExpr(raw, lookup, depth + 1);
|
| 312 |
+
}
|
| 313 |
+
return null;
|
| 314 |
+
}
|
dssl-vscode/lib/engine/import.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: import.js
|
| 3 |
+
*
|
| 4 |
+
* dssl import styles.css -o styles.dssl
|
| 5 |
+
*
|
| 6 |
+
* Turns an existing CSS file into DSSL, and tells you honestly what it could
|
| 7 |
+
* not carry over. It is a translator for the mechanical differences, not a
|
| 8 |
+
* magic wand:
|
| 9 |
+
*
|
| 10 |
+
* semicolons dropped, bare numbers for pixel lengths (16px -> 16)
|
| 11 |
+
* transition: color .2s ease -> transition: 200 ease color
|
| 12 |
+
* font: italic 700 14px/1.5 Arial -> the five longhands (DSSL's font: means something else)
|
| 13 |
+
* @keyframes -> motion { from / at 40% / to }
|
| 14 |
+
* !important -> the same declaration inside pin { }
|
| 15 |
+
* @font-face, @supports, @import, @property ... -> one raw { } block, verbatim
|
| 16 |
+
* properties DSSL does not know -> commented out, and listed in the report
|
| 17 |
+
*
|
| 18 |
+
* Everything else is kept as written, because DSSL selectors and CSS values are
|
| 19 |
+
* CSS. The result is then run through the real compiler and any error it still
|
| 20 |
+
* has is in the report, so you know before you open the file.
|
| 21 |
+
*
|
| 22 |
+
* Pure: no DOM, no filesystem. importCss(text) -> { dssl, report }.
|
| 23 |
+
* ========================================================================= */
|
| 24 |
+
|
| 25 |
+
import { parse } from './parser.js';
|
| 26 |
+
import { resolve, KNOWN_PROPS, UNITLESS_PROPS, EASINGS } from './resolver.js';
|
| 27 |
+
|
| 28 |
+
const RAW_AT_RULES = new Set(['font-face', 'supports', 'import', 'property', 'page', 'charset', 'namespace', 'counter-style', 'font-feature-values', 'font-palette-values', 'view-transition', 'scope', 'starting-style', 'position-try']);
|
| 29 |
+
const SEMANTIC_NAMES = new Set(['size', 'place', 'center', 'cover', 'sticky', 'radius', 'shadow', 'circle', 'z', 'grow', 'shrink', 'basis', 'stack', 'fluid', 'texture', 'aura', 'slider', 'bend', 'fold', 'stretch', 'gradient', 'animate', 'stagger', 'overlap']);
|
| 30 |
+
|
| 31 |
+
/* ------------------------------------------------------------------ *
|
| 32 |
+
* 1. a small CSS parser
|
| 33 |
+
* ------------------------------------------------------------------ */
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Parse CSS into a tree. Handles comments, strings, url(), parentheses and
|
| 37 |
+
* nesting. Nodes:
|
| 38 |
+
* { type:'decl', prop, value, important, line }
|
| 39 |
+
* { type:'rule', sel, body:[nodes], line }
|
| 40 |
+
* { type:'at', name, prelude, body:[nodes]|null, raw, line }
|
| 41 |
+
* { type:'comment', text, line }
|
| 42 |
+
*/
|
| 43 |
+
export function parseCss(src) {
|
| 44 |
+
let i = 0;
|
| 45 |
+
const n = src.length;
|
| 46 |
+
let line = 1;
|
| 47 |
+
const lineAt = (idx) => { let l = 1; for (let k = 0; k < idx && k < n; k++) if (src.charCodeAt(k) === 10) l++; return l; };
|
| 48 |
+
|
| 49 |
+
function skipString(q) {
|
| 50 |
+
i++;
|
| 51 |
+
while (i < n && src[i] !== q) { if (src[i] === '\\') i++; i++; }
|
| 52 |
+
i++;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function parseBody(inBlock) {
|
| 56 |
+
const nodes = [];
|
| 57 |
+
let buf = '';
|
| 58 |
+
let bufStart = i;
|
| 59 |
+
let paren = 0;
|
| 60 |
+
const flushDecl = (end) => {
|
| 61 |
+
const text = buf.trim();
|
| 62 |
+
buf = '';
|
| 63 |
+
if (!text) return;
|
| 64 |
+
const colon = text.indexOf(':');
|
| 65 |
+
if (colon === -1) { nodes.push({ type: 'junk', text, line: lineAt(end) }); return; }
|
| 66 |
+
const prop = text.slice(0, colon).trim();
|
| 67 |
+
let value = text.slice(colon + 1).trim();
|
| 68 |
+
let important = false;
|
| 69 |
+
const im = /\s*!\s*important\s*$/i.exec(value);
|
| 70 |
+
if (im) { important = true; value = value.slice(0, im.index).trim(); }
|
| 71 |
+
nodes.push({ type: 'decl', prop, value, important, line: lineAt(bufStart) });
|
| 72 |
+
};
|
| 73 |
+
while (i < n) {
|
| 74 |
+
const c = src[i];
|
| 75 |
+
if (c === '/' && src[i + 1] === '*') {
|
| 76 |
+
const end = src.indexOf('*/', i + 2);
|
| 77 |
+
const stop = end === -1 ? n : end + 2;
|
| 78 |
+
if (!buf.trim()) nodes.push({ type: 'comment', text: src.slice(i + 2, end === -1 ? n : end).trim(), line: lineAt(i) });
|
| 79 |
+
i = stop;
|
| 80 |
+
continue;
|
| 81 |
+
}
|
| 82 |
+
if (c === '"' || c === "'") { const s = i; skipString(c); buf += src.slice(s, i); continue; }
|
| 83 |
+
if (c === '(') { paren++; buf += c; i++; continue; }
|
| 84 |
+
if (c === ')') { paren--; buf += c; i++; continue; }
|
| 85 |
+
if (paren > 0) { buf += c; i++; continue; }
|
| 86 |
+
if (c === ';') { const at = i; i++; flushAt(at); continue; }
|
| 87 |
+
if (c === '{') {
|
| 88 |
+
const prelude = buf.trim();
|
| 89 |
+
const startIdx = bufStart;
|
| 90 |
+
buf = '';
|
| 91 |
+
const startLine = lineAt(startIdx);
|
| 92 |
+
i++;
|
| 93 |
+
const body = parseBody(true);
|
| 94 |
+
const node = { line: startLine };
|
| 95 |
+
const atm = prelude.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(prelude) : null;
|
| 96 |
+
if (atm) {
|
| 97 |
+
Object.assign(node, { type: 'at', name: atm[1].toLowerCase(), prelude: atm[2].trim(), body, raw: src.slice(startIdx, i).trim() });
|
| 98 |
+
} else if (prelude && !prelude.startsWith('@')) {
|
| 99 |
+
Object.assign(node, { type: 'rule', sel: prelude, body });
|
| 100 |
+
} else {
|
| 101 |
+
// "@@@ {" or a block with no selector at all: nothing sensible to convert
|
| 102 |
+
Object.assign(node, { type: 'junk', text: prelude || '{ ... }' });
|
| 103 |
+
}
|
| 104 |
+
nodes.push(node);
|
| 105 |
+
bufStart = i;
|
| 106 |
+
continue;
|
| 107 |
+
}
|
| 108 |
+
if (c === '}') {
|
| 109 |
+
if (inBlock) { flushDecl(i); i++; return nodes; }
|
| 110 |
+
i++; continue; // a stray brace at the top level
|
| 111 |
+
}
|
| 112 |
+
if (!buf.trim()) bufStart = i;
|
| 113 |
+
buf += c; i++;
|
| 114 |
+
}
|
| 115 |
+
flushDecl(i);
|
| 116 |
+
return nodes;
|
| 117 |
+
|
| 118 |
+
// a ";" ended something: a declaration, or an at-rule with no block (@import ...;)
|
| 119 |
+
function flushAt(at) {
|
| 120 |
+
const text = buf.trim();
|
| 121 |
+
const m = text.startsWith('@') ? /^@([\w-]+)\s*([\s\S]*)$/.exec(text) : null;
|
| 122 |
+
if (text.startsWith('@') && !m) { nodes.push({ type: 'junk', text, line: lineAt(bufStart) }); buf = ''; }
|
| 123 |
+
else if (m) {
|
| 124 |
+
nodes.push({ type: 'at', name: m[1].toLowerCase(), prelude: m[2].trim(), body: null, raw: text + ';', line: lineAt(bufStart) });
|
| 125 |
+
buf = '';
|
| 126 |
+
} else flushDecl(at);
|
| 127 |
+
bufStart = i;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
return parseBody(false);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* ------------------------------------------------------------------ *
|
| 134 |
+
* 2. value conversion
|
| 135 |
+
* ------------------------------------------------------------------ */
|
| 136 |
+
|
| 137 |
+
/** split on top-level whitespace or commas, keeping rgb(1 2 3), url(a b) etc. whole */
|
| 138 |
+
function topSplit(s, sep) {
|
| 139 |
+
const out = []; let depth = 0, cur = '', q = null;
|
| 140 |
+
for (const ch of s) {
|
| 141 |
+
if (q) { cur += ch; if (ch === q) q = null; continue; }
|
| 142 |
+
if (ch === '"' || ch === "'") { q = ch; cur += ch; continue; }
|
| 143 |
+
if (ch === '(') depth++;
|
| 144 |
+
if (ch === ')') depth--;
|
| 145 |
+
const hit = sep === ',' ? ch === ',' : /\s/.test(ch);
|
| 146 |
+
if (hit && depth === 0) { if (cur.trim()) out.push(cur.trim()); cur = ''; continue; }
|
| 147 |
+
cur += ch;
|
| 148 |
+
}
|
| 149 |
+
if (cur.trim()) out.push(cur.trim());
|
| 150 |
+
return out;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const PX = /^(-?\d*\.?\d+)px$/;
|
| 154 |
+
|
| 155 |
+
function stripPx(prop, value) {
|
| 156 |
+
if (prop.startsWith('--') || UNITLESS_PROPS.has(prop)) return value;
|
| 157 |
+
// only top-level words: a px inside calc() or min() is left as written
|
| 158 |
+
return topSplit(value, ' ').map((w) => (PX.test(w) ? w.replace(PX, '$1') : w)).join(' ');
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const msOf = (t) => {
|
| 162 |
+
const m = /^(\d*\.?\d+)(ms|s)$/.exec(t);
|
| 163 |
+
if (!m) return null;
|
| 164 |
+
return m[2] === 's' ? Math.round(parseFloat(m[1]) * 1000) : Math.round(parseFloat(m[1]));
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
function convertTransition(value, note) {
|
| 168 |
+
if (value.trim() === 'none') return 'none';
|
| 169 |
+
const out = [];
|
| 170 |
+
for (const seg of topSplit(value, ',')) {
|
| 171 |
+
const atoms = topSplit(seg, ' ');
|
| 172 |
+
const times = atoms.filter((a) => msOf(a) !== null);
|
| 173 |
+
const ease = atoms.find((a) => EASINGS[a] || /^(cubic-bezier|steps)\(/.test(a));
|
| 174 |
+
const props = atoms.filter((a) => msOf(a) === null && a !== ease);
|
| 175 |
+
if (!times.length) { note(`transition "${seg}" has no duration, so it was dropped`); continue; }
|
| 176 |
+
if (times.length > 1) note('a transition delay has no DSSL form here, so it was dropped (use transition-delay)');
|
| 177 |
+
const prop = props.join(' ');
|
| 178 |
+
out.push([String(msOf(times[0])), ease, prop && prop !== 'all' ? prop : ''].filter(Boolean).join(' '));
|
| 179 |
+
}
|
| 180 |
+
return out.join(', ');
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/** font: [style] [weight] size[/line-height] family -> longhands */
|
| 184 |
+
function convertFont(value, note) {
|
| 185 |
+
const SYSTEM = /^(caption|icon|menu|message-box|small-caption|status-bar|inherit|initial|unset)$/;
|
| 186 |
+
if (SYSTEM.test(value.trim())) { note(`font: ${value} is a system font keyword with no DSSL equivalent`); return null; }
|
| 187 |
+
const atoms = topSplit(value, ' ');
|
| 188 |
+
const sizeIdx = atoms.findIndex((a) => /^(-?\d*\.?\d+)(px|em|rem|%|pt|vw|vh)?(\/.+)?$/.test(a) && !/^\d{3}$/.test(a) && !/^\d00$/.test(a)
|
| 189 |
+
|| /^(xx-small|x-small|small|medium|large|x-large|xx-large)(\/.+)?$/.test(a));
|
| 190 |
+
if (sizeIdx === -1) { note(`font: ${value} has no size, so it could not be split`); return null; }
|
| 191 |
+
const out = [];
|
| 192 |
+
for (const a of atoms.slice(0, sizeIdx)) {
|
| 193 |
+
if (/^(italic|oblique)$/.test(a)) out.push(['font-style', a]);
|
| 194 |
+
else if (/^(\d{3}|bold|bolder|lighter)$/.test(a)) out.push(['font-weight', a]);
|
| 195 |
+
else if (a === 'small-caps') out.push(['font-variant', a]);
|
| 196 |
+
}
|
| 197 |
+
const [size, lh] = atoms[sizeIdx].split('/');
|
| 198 |
+
out.push(['font-size', size]);
|
| 199 |
+
if (lh) out.push(['line-height', lh]);
|
| 200 |
+
const fam = atoms.slice(sizeIdx + 1).join(' ');
|
| 201 |
+
if (fam) out.push(['font-family', fam]);
|
| 202 |
+
return out;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* ------------------------------------------------------------------ *
|
| 206 |
+
* 3. the converter
|
| 207 |
+
* ------------------------------------------------------------------ */
|
| 208 |
+
|
| 209 |
+
export function importCss(css, options = {}) {
|
| 210 |
+
const tree = parseCss(String(css));
|
| 211 |
+
const report = { rules: 0, declarations: 0, converted: 0, pinned: 0, raw: 0, motions: 0, dropped: [], notes: [], compile: null };
|
| 212 |
+
const rawChunks = [];
|
| 213 |
+
const pinBlocks = [];
|
| 214 |
+
const note = (line, message, hint) => report.notes.push({ line, message, hint });
|
| 215 |
+
const pad = (d) => ' '.repeat(d);
|
| 216 |
+
|
| 217 |
+
const emitDecl = (d, depth, out, collectPin) => {
|
| 218 |
+
report.declarations++;
|
| 219 |
+
const line = d.line;
|
| 220 |
+
const prop = d.prop.toLowerCase();
|
| 221 |
+
if (!prop.startsWith('--') && !KNOWN_PROPS.has(prop) && prop !== 'transition' && prop !== 'font' && prop !== 'animation' && !SEMANTIC_NAMES.has(prop)) {
|
| 222 |
+
out.push(`${pad(depth)}// dssl import dropped (unknown to DSSL): ${d.prop}: ${d.value}`);
|
| 223 |
+
report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'DSSL does not know this property' });
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
if (SEMANTIC_NAMES.has(prop) && prop !== 'font') {
|
| 227 |
+
note(line, `"${d.prop}" is also a DSSL shorthand with a different meaning`, 'check this line after import');
|
| 228 |
+
}
|
| 229 |
+
if (d.important) {
|
| 230 |
+
collectPin.push({ prop, value: d.value, line });
|
| 231 |
+
report.pinned++;
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
let value = d.value;
|
| 235 |
+
if (prop === 'transition') {
|
| 236 |
+
value = convertTransition(value, (m) => note(line, m));
|
| 237 |
+
if (!value) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'no usable duration' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
|
| 238 |
+
report.converted++;
|
| 239 |
+
out.push(`${pad(depth)}transition: ${value}`);
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
if (prop === 'font') {
|
| 243 |
+
const parts = convertFont(value, (m) => note(line, m));
|
| 244 |
+
if (!parts) { report.dropped.push({ line, text: `${d.prop}: ${d.value}`, reason: 'font shorthand could not be split' }); out.push(`${pad(depth)}// dssl import dropped: ${d.prop}: ${d.value}`); return; }
|
| 245 |
+
report.converted++;
|
| 246 |
+
for (const [p, v] of parts) out.push(`${pad(depth)}${p}: ${stripPx(p, v)}`);
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
if (prop === 'grid') note(line, 'grid: is a CSS shorthand here but a different sugar in DSSL', 'it was kept as written; rewrite it with grid-template-* if the compiler complains');
|
| 250 |
+
const next = stripPx(prop, value);
|
| 251 |
+
if (next !== value) report.converted++;
|
| 252 |
+
out.push(`${pad(depth)}${d.prop}: ${next}`);
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const emitNodes = (nodes, depth, out) => {
|
| 256 |
+
for (const node of nodes) {
|
| 257 |
+
if (node.type === 'comment') { if (depth === 0 || true) out.push(`${pad(depth)}// ${node.text.replace(/\n\s*/g, ' ')}`); continue; }
|
| 258 |
+
if (node.type === 'junk') { note(node.line, `could not read "${node.text.slice(0, 40)}"`); continue; }
|
| 259 |
+
if (node.type === 'decl') { emitDecl(node, depth, out, out.__pin || (out.__pin = [])); continue; }
|
| 260 |
+
if (node.type === 'rule') {
|
| 261 |
+
report.rules++;
|
| 262 |
+
const lines = [];
|
| 263 |
+
lines.__pin = [];
|
| 264 |
+
emitNodes(node.body, depth + 1, lines);
|
| 265 |
+
out.push(`${pad(depth)}${node.sel} {`, ...lines, `${pad(depth)}}`);
|
| 266 |
+
if (lines.__pin.length) pinBlocks.push({ sel: node.sel, decls: lines.__pin, depth });
|
| 267 |
+
continue;
|
| 268 |
+
}
|
| 269 |
+
if (node.type === 'at') {
|
| 270 |
+
const name = node.name;
|
| 271 |
+
if (name === 'keyframes' || name.endsWith('-keyframes')) {
|
| 272 |
+
if (name !== 'keyframes') { note(node.line, `@${name} is a vendor copy; the plain @keyframes is used`); continue; }
|
| 273 |
+
report.motions++;
|
| 274 |
+
out.push(`${pad(depth)}motion ${node.prelude} {`);
|
| 275 |
+
for (const fr of node.body || []) {
|
| 276 |
+
if (fr.type !== 'rule') continue;
|
| 277 |
+
for (const label of fr.sel.split(',').map((s) => s.trim())) {
|
| 278 |
+
const key = label === '0%' ? 'from' : label === '100%' ? 'to' : /%$/.test(label) ? `at ${label}` : label;
|
| 279 |
+
const inner = [];
|
| 280 |
+
inner.__pin = [];
|
| 281 |
+
emitNodes(fr.body, depth + 2, inner);
|
| 282 |
+
out.push(`${pad(depth + 1)}${key} {`, ...inner, `${pad(depth + 1)}}`);
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
out.push(`${pad(depth)}}`);
|
| 286 |
+
continue;
|
| 287 |
+
}
|
| 288 |
+
if (RAW_AT_RULES.has(name)) {
|
| 289 |
+
report.raw++;
|
| 290 |
+
rawChunks.push(node.raw);
|
| 291 |
+
if (name === 'import') note(node.line, '@import was moved into raw { }', 'load other stylesheets with <link rel="stylesheet/dssl"> or <link rel="stylesheet"> instead');
|
| 292 |
+
continue;
|
| 293 |
+
}
|
| 294 |
+
if (name === 'media' || name === 'container') {
|
| 295 |
+
const lines = [];
|
| 296 |
+
lines.__pin = [];
|
| 297 |
+
emitNodes(node.body || [], depth + 1, lines);
|
| 298 |
+
out.push(`${pad(depth)}@${name} ${node.prelude} {`, ...lines, `${pad(depth)}}`);
|
| 299 |
+
if (lines.__pin.length) note(node.line, `!important inside @${name} was moved to pin { } and loses the media condition`, 'wrap it in @media inside pin by hand');
|
| 300 |
+
continue;
|
| 301 |
+
}
|
| 302 |
+
if (name === 'layer') {
|
| 303 |
+
if (!node.body) { note(node.line, `@layer ${node.prelude}; is dropped: DSSL's layers are fixed (tokens, base, components, utilities, pin)`); continue; }
|
| 304 |
+
const lines = [];
|
| 305 |
+
lines.__pin = [];
|
| 306 |
+
emitNodes(node.body, depth + 1, lines);
|
| 307 |
+
out.push(`${pad(depth)}@layer ${node.prelude} {`, ...lines, `${pad(depth)}}`);
|
| 308 |
+
continue;
|
| 309 |
+
}
|
| 310 |
+
report.raw++;
|
| 311 |
+
rawChunks.push(node.raw);
|
| 312 |
+
note(node.line, `@${name} was kept verbatim in raw { }`);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
const lines = [];
|
| 318 |
+
lines.__pin = [];
|
| 319 |
+
emitNodes(tree, 0, lines);
|
| 320 |
+
|
| 321 |
+
let dssl = lines.join('\n');
|
| 322 |
+
if (pinBlocks.length) {
|
| 323 |
+
dssl += `\n\n// !important in CSS is pin { } in DSSL: the layer that always wins\npin {\n`;
|
| 324 |
+
for (const b of pinBlocks) {
|
| 325 |
+
dssl += ` ${b.sel} {\n${b.decls.map((d) => ` ${d.prop}: ${stripPx(d.prop, d.value)}`).join('\n')}\n }\n`;
|
| 326 |
+
}
|
| 327 |
+
dssl += `}`;
|
| 328 |
+
}
|
| 329 |
+
if (rawChunks.length) {
|
| 330 |
+
dssl = `// raw { } is real CSS, so semicolons are required inside it\nraw {\n${rawChunks.map((c) => ' ' + c.replace(/\n/g, '\n ')).join('\n')}\n}\n\n` + dssl;
|
| 331 |
+
}
|
| 332 |
+
dssl = dssl.trim() + '\n';
|
| 333 |
+
|
| 334 |
+
// Run the real compiler on the result. A declaration it rejects (valid CSS that DSSL's
|
| 335 |
+
// stricter value checking does not accept, like scroll-snap-type: x mandatory) is commented
|
| 336 |
+
// out and listed, so the file you get always compiles.
|
| 337 |
+
const check = () => {
|
| 338 |
+
try {
|
| 339 |
+
const { ast, errors: parseErrors } = parse(dssl, 'imported.dssl');
|
| 340 |
+
const doc = resolve(ast, 'imported.dssl');
|
| 341 |
+
return { errors: [...parseErrors, ...doc.errors], warnings: doc.warnings.length };
|
| 342 |
+
} catch (e) {
|
| 343 |
+
return { errors: [{ line: 0, message: `the compiler threw: ${e.message}` }], warnings: 0 };
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
let result = check();
|
| 347 |
+
for (let pass = 0; pass < 4 && result.errors.length; pass++) {
|
| 348 |
+
const lines = dssl.split('\n');
|
| 349 |
+
const seen = new Set();
|
| 350 |
+
let changed = false;
|
| 351 |
+
for (const e of result.errors) {
|
| 352 |
+
const idx = (e.line || 0) - 1;
|
| 353 |
+
if (idx < 0 || idx >= lines.length || seen.has(idx)) continue;
|
| 354 |
+
const text = lines[idx];
|
| 355 |
+
if (!/^\s*[-\w]+\s*:/.test(text) || /^\s*\/\//.test(text)) continue; // only declaration lines
|
| 356 |
+
seen.add(idx);
|
| 357 |
+
const indent = text.match(/^\s*/)[0];
|
| 358 |
+
lines[idx] = `${indent}// dssl import dropped (the compiler rejected it: ${e.message}): ${text.trim()}`;
|
| 359 |
+
report.dropped.push({ line: e.line, text: text.trim(), reason: e.message });
|
| 360 |
+
changed = true;
|
| 361 |
+
}
|
| 362 |
+
if (!changed) break;
|
| 363 |
+
dssl = lines.join('\n');
|
| 364 |
+
result = check();
|
| 365 |
+
}
|
| 366 |
+
report.compile = {
|
| 367 |
+
ok: result.errors.length === 0,
|
| 368 |
+
errors: result.errors.map((e) => ({ line: e.line, message: e.message, hint: e.hint })),
|
| 369 |
+
warnings: result.warnings,
|
| 370 |
+
};
|
| 371 |
+
return { dssl, report };
|
| 372 |
+
}
|
dssl-vscode/lib/engine/lint.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: lint.js
|
| 3 |
+
*
|
| 4 |
+
* Warnings for things that compile and run and are still wrong for the people
|
| 5 |
+
* using the page. They fit the language's promise (a mistake should be loud),
|
| 6 |
+
* and they never stop a build unless you ask for `dssl check --strict`.
|
| 7 |
+
*
|
| 8 |
+
* contrast text colour against its own background, in the same rule
|
| 9 |
+
* focus an outline removed from something you can tab to, with nothing in its place
|
| 10 |
+
* target a button or link with an explicit size smaller than a thumb can hit
|
| 11 |
+
*
|
| 12 |
+
* Only unconditional rules are checked. A rule inside @media or @signal might be
|
| 13 |
+
* the dark theme, and judging it against the light tokens would be wrong.
|
| 14 |
+
*
|
| 15 |
+
* Pure: no DOM. Called by resolve() with the rules it just built.
|
| 16 |
+
* ========================================================================= */
|
| 17 |
+
|
| 18 |
+
import { parseColor, evalColorExpr, over, contrast, toRgba, toHex } from './color.js';
|
| 19 |
+
|
| 20 |
+
/** one design-token lookup closed over the sheet's token map */
|
| 21 |
+
function makeResolver(tokenMap) {
|
| 22 |
+
const lookup = (name) => tokenMap.get(name);
|
| 23 |
+
/** CSS value as the resolver emits it ("var(--dssl-color-ink)", "color-mix(...)", "#fff") to a colour */
|
| 24 |
+
return function cssToColor(css, depth = 0) {
|
| 25 |
+
if (css === undefined || css === null || depth > 6) return null;
|
| 26 |
+
const s = String(css).trim();
|
| 27 |
+
const direct = parseColor(s);
|
| 28 |
+
if (direct) return direct;
|
| 29 |
+
if (!s.includes('var(')) return null;
|
| 30 |
+
let unresolved = false;
|
| 31 |
+
const expanded = s.replace(/var\(--dssl-([a-z0-9-]+)\)/g, (m, name) => {
|
| 32 |
+
const key = name.replace('-', '.'); // namespace is the first segment: color-code-bg is color.code-bg
|
| 33 |
+
const raw = lookup(key);
|
| 34 |
+
if (raw === undefined) { unresolved = true; return m; }
|
| 35 |
+
const c = evalColorExpr(raw, lookup);
|
| 36 |
+
if (!c) { unresolved = true; return m; }
|
| 37 |
+
return toRgba(c);
|
| 38 |
+
});
|
| 39 |
+
if (unresolved) return null;
|
| 40 |
+
return cssToColor(expanded, depth + 1);
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const lastDecl = (decls, ...props) => { for (let i = decls.length - 1; i >= 0; i--) if (props.includes(decls[i].prop)) return decls[i]; return null; };
|
| 45 |
+
const pxOf = (v) => { const m = /^(-?[\d.]+)px$/.exec(String(v || '').trim()); return m ? parseFloat(m[1]) : null; };
|
| 46 |
+
const finalCompound = (sel) => String(sel).trim().split(/[\s>+~]+/).pop() || '';
|
| 47 |
+
const INTERACTIVE = /^(a|button|input|select|textarea|summary)\b|\[tabindex|\[role=["']?(button|link|tab|menuitem)|^\*$/;
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* @param {object} p { rules, tokenMap, file }
|
| 51 |
+
* @returns {Array} warnings in the compiler's {file, line, message, hint} shape
|
| 52 |
+
*/
|
| 53 |
+
export function lintRules({ rules, tokenMap, file }) {
|
| 54 |
+
const warnings = [];
|
| 55 |
+
const cssToColor = makeResolver(tokenMap);
|
| 56 |
+
const pageBg = (() => { const raw = tokenMap.get('color.paper'); return raw ? evalColorExpr(raw, (n) => tokenMap.get(n)) : null; })();
|
| 57 |
+
|
| 58 |
+
const killedFocus = [];
|
| 59 |
+
const focusBases = new Set();
|
| 60 |
+
|
| 61 |
+
for (const r of rules) {
|
| 62 |
+
if (r.pseudo) continue;
|
| 63 |
+
const conditional = (r.media && r.media.length) || (r.signals && r.signals.length) || (r.containers && r.containers.length);
|
| 64 |
+
const states = r.states || new Set();
|
| 65 |
+
const hasFocus = states.has('focus') || states.has('focus-visible');
|
| 66 |
+
|
| 67 |
+
// ---- contrast
|
| 68 |
+
if (!conditional) {
|
| 69 |
+
const fgD = lastDecl(r.decls, 'color');
|
| 70 |
+
const bgD = lastDecl(r.decls, 'background-color', 'background');
|
| 71 |
+
if (fgD && bgD && typeof fgD.value === 'string' && typeof bgD.value === 'string') {
|
| 72 |
+
let bg = cssToColor(bgD.value);
|
| 73 |
+
let fg = cssToColor(fgD.value);
|
| 74 |
+
if (bg && fg) {
|
| 75 |
+
if (bg.a < 0.999) bg = pageBg ? over(bg, { ...pageBg, a: 1 }) : null;
|
| 76 |
+
if (bg) {
|
| 77 |
+
const fgSolid = fg.a < 0.999 ? over(fg, bg) : fg;
|
| 78 |
+
const ratio = contrast(fgSolid, bg);
|
| 79 |
+
const size = pxOf((lastDecl(r.decls, 'font-size') || {}).value);
|
| 80 |
+
const weightRaw = (lastDecl(r.decls, 'font-weight') || {}).value;
|
| 81 |
+
const weight = weightRaw === 'bold' ? 700 : Number(weightRaw) || 400;
|
| 82 |
+
const large = size !== null && (size >= 24 || (size >= 18.66 && weight >= 700));
|
| 83 |
+
const need = large ? 3 : 4.5;
|
| 84 |
+
if (ratio < need) {
|
| 85 |
+
warnings.push({
|
| 86 |
+
file, line: fgD.line,
|
| 87 |
+
message: `text contrast is ${ratio.toFixed(1)}:1 (${toHex(fgSolid)} on ${toHex(bg)}), below the ${need}:1 that readers need${large ? ' for large text' : ''}`,
|
| 88 |
+
hint: 'pick a darker or lighter text colour (WCAG AA). Contrast is only checked for a colour and a background written in the same rule',
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// ---- focus
|
| 97 |
+
if (hasFocus) focusBases.add(r.sel.replace(/:(focus-visible|focus)\b/g, '').trim());
|
| 98 |
+
if (!hasFocus && INTERACTIVE.test(finalCompound(r.sel).replace(/[.#:][\w-]+.*$/, (m) => m.startsWith('[') ? m : '') || finalCompound(r.sel))) {
|
| 99 |
+
for (const d of r.decls) {
|
| 100 |
+
const v = String(d.value).trim();
|
| 101 |
+
const killed = (d.prop === 'outline' && /^(none|0|0px)(\s|$)/.test(v)) || (d.prop === 'outline-style' && v === 'none') || (d.prop === 'outline-width' && /^0(px)?$/.test(v));
|
| 102 |
+
if (killed) { killedFocus.push({ sel: r.sel, line: d.line }); break; }
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ---- tap target
|
| 107 |
+
const tag = finalCompound(r.sel);
|
| 108 |
+
if (!conditional && /^(button|a)\b|\[role=["']?button/.test(tag)) {
|
| 109 |
+
for (const dim of ['width', 'height', 'min-width', 'min-height']) {
|
| 110 |
+
const d = lastDecl(r.decls, dim);
|
| 111 |
+
const px = d && pxOf(d.value);
|
| 112 |
+
if (px !== null && px !== undefined && px < 24 && px > 0) {
|
| 113 |
+
warnings.push({
|
| 114 |
+
file, line: d.line,
|
| 115 |
+
message: `${dim}: ${px}px on "${r.sel}" is below 24px, the smallest target WCAG 2.2 allows`,
|
| 116 |
+
hint: 'give buttons and links at least 24px (44px is comfortable for a thumb), or add padding instead of shrinking the box',
|
| 117 |
+
});
|
| 118 |
+
break;
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
for (const k of killedFocus) {
|
| 125 |
+
if (focusBases.has(k.sel)) continue;
|
| 126 |
+
warnings.push({
|
| 127 |
+
file, line: k.line,
|
| 128 |
+
message: `the focus outline is removed from "${k.sel}" and nothing replaces it, so keyboard users lose their place`,
|
| 129 |
+
hint: 'add a focus-visible { outline: 2 solid color.accent } block, or `use focus-ring` if you have that behavior',
|
| 130 |
+
});
|
| 131 |
+
}
|
| 132 |
+
return warnings;
|
| 133 |
+
}
|
dssl-vscode/lib/engine/parser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
/* =========================================================================
|
| 2 |
-
* DSSL
|
| 3 |
-
* parser.js
|
| 4 |
*
|
| 5 |
* Line-oriented parser:
|
| 6 |
* - blocks open with `header {` and close with `}`
|
|
@@ -18,27 +18,27 @@ export const STATE_WORDS = new Set([
|
|
| 18 |
'placeholder-shown', 'read-only', 'read-write', 'enabled',
|
| 19 |
'indeterminate', 'first-child', 'last-child', 'only-child',
|
| 20 |
'first-of-type', 'last-of-type', 'link', 'visited',
|
| 21 |
-
'seen', // v0.4.0
|
| 22 |
]);
|
| 23 |
|
| 24 |
-
// v0.5.0
|
| 25 |
// full selector (.card::before) carries a pseudo-element. The resolver splits
|
| 26 |
-
// those into element + pseudo and routes them into shadow CSS
|
| 27 |
// top-level `.card::before { }` compiled ok and then matched NOTHING.
|
| 28 |
export const PSEUDO_ELEMENTS = new Set([
|
| 29 |
'before', 'after', 'placeholder', 'selection', 'marker',
|
| 30 |
'first-line', 'first-letter', 'backdrop', 'file-selector-button',
|
| 31 |
-
// v0.7.2
|
| 32 |
// CAN style shadow parts of custom elements). ::slotted() is accepted by
|
| 33 |
// the parser but rejected by the resolver with a loud explanation: it only
|
| 34 |
// exists inside a shadow tree, which is not a place DSSL styles.
|
| 35 |
'part', 'slotted',
|
| 36 |
]);
|
| 37 |
|
| 38 |
-
// v0.4.2
|
| 39 |
// and friends). The DOM gives you no other way to reach native scrollbars,
|
| 40 |
// so they ride the same shadow-CSS exception as ::before/::after. The name
|
| 41 |
-
// may carry trailing state (::hover, :horizontal)
|
| 42 |
export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
|
| 43 |
|
| 44 |
/** find index of first top-level occurrence of `ch` (skips strings + parens) */
|
|
@@ -113,7 +113,7 @@ function stripComments(src) {
|
|
| 113 |
if (c === '"' || c === "'") { q = c; out += c; continue; }
|
| 114 |
if (c === '/' && n === '/') {
|
| 115 |
// v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
|
| 116 |
-
// the main loop emitted the same newline again
|
| 117 |
// doubled a line, and every line number after the first comment
|
| 118 |
// drifted +1 (diagnostics pointed one line late). The newline is now
|
| 119 |
// emitted exactly once by the main loop.
|
|
@@ -156,13 +156,13 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 156 |
if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
|
| 157 |
if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
|
| 158 |
if (h.startsWith('@')) {
|
| 159 |
-
// v0.8.0
|
| 160 |
// used to error and then keep parsing inside their block, so the inner
|
| 161 |
// rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
|
| 162 |
-
// applied .a everywhere. The block is now dropped in its entirety
|
| 163 |
// and raw { } is the documented escape hatch for CSS DSSL has no sugar
|
| 164 |
// for yet (@font-face, @keyframes, @supports, @import…).
|
| 165 |
-
err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped
|
| 166 |
return { kind: 'bogus' };
|
| 167 |
}
|
| 168 |
if (h.startsWith('::')) {
|
|
@@ -198,7 +198,7 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 198 |
|
| 199 |
if (open !== -1) {
|
| 200 |
const header = rest.slice(0, open).trim();
|
| 201 |
-
// v0.7.2
|
| 202 |
// `.card { background: red; hover { ... } }`. Everything before the
|
| 203 |
// header used to be swallowed whole into it, so `background: red`
|
| 204 |
// silently became part of a selector named "background: red; hover"
|
|
@@ -234,9 +234,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 234 |
}
|
| 235 |
};
|
| 236 |
|
| 237 |
-
/** v0.8.0
|
| 238 |
* - raw { } (top level): capture the block body VERBATIM as node.css and
|
| 239 |
-
* return the remainder of the chunk
|
| 240 |
* (returns '' with rawCapture armed).
|
| 241 |
* - bogus (unknown at-rule / pseudo-element): skip the whole balanced
|
| 242 |
* block so its content can never leak into the parent.
|
|
@@ -246,7 +246,7 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 246 |
const openBlock = (meta, node, rest, open, line) => {
|
| 247 |
if (meta.kind === 'raw') {
|
| 248 |
if (stack.length > 1) {
|
| 249 |
-
err(line, 'raw { } must be top-level
|
| 250 |
const close = matchBrace(rest, open + 1);
|
| 251 |
return close === -1 ? '' : rest.slice(close + 1);
|
| 252 |
}
|
|
@@ -303,13 +303,13 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 303 |
if (!part) continue;
|
| 304 |
const colon = scanFor(part, ':');
|
| 305 |
if (colon === -1) {
|
| 306 |
-
// `use behavior-name`
|
| 307 |
const useMatch = part.match(/^use\s+([A-Za-z][\w-]*)$/);
|
| 308 |
if (useMatch) {
|
| 309 |
stack[stack.length - 1].children.push({ kind: 'decl', prop: 'use', value: useMatch[1], line });
|
| 310 |
continue;
|
| 311 |
}
|
| 312 |
-
// valueless semantic property (e.g. `truncate`)
|
| 313 |
if (/^[a-z][a-z0-9-]*$/i.test(part)) {
|
| 314 |
stack[stack.length - 1].children.push({ kind: 'decl', prop: part.toLowerCase(), value: '', line });
|
| 315 |
} else {
|
|
@@ -320,9 +320,9 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 320 |
const prop = part.slice(0, colon).trim().toLowerCase();
|
| 321 |
const value = part.slice(colon + 1).trim();
|
| 322 |
if (!prop) { err(line, 'declaration is missing a property name'); continue; }
|
| 323 |
-
// v0.7.0
|
| 324 |
// the built-in spacing scale stops at 8, and real pages need 9, 10, 12…
|
| 325 |
-
// Numbers are keys ONLY inside a tokens subtree
|
| 326 |
// numeric property is a typo, and the error now says exactly that
|
| 327 |
// instead of the old "not a valid property name" shrug.
|
| 328 |
if (/^[0-9]+(\.[0-9]+)?$/.test(prop)) {
|
|
@@ -331,10 +331,10 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 331 |
continue;
|
| 332 |
}
|
| 333 |
err(line, `"${prop}" is not a valid property name`,
|
| 334 |
-
'numeric names are token keys only inside tokens { }
|
| 335 |
continue;
|
| 336 |
}
|
| 337 |
-
// v0.4.0: dotted custom properties are legal
|
| 338 |
// scoped-token sugar (resolver maps it to the engine's token variable)
|
| 339 |
if (!/^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$/i.test(prop)) {
|
| 340 |
err(line, `"${prop}" is not a valid property name`,
|
|
@@ -380,7 +380,7 @@ export function parse(source, file = 'inline.dssl') {
|
|
| 380 |
});
|
| 381 |
if (buf.trim()) processChunk(buf, bufLine);
|
| 382 |
if (rawCapture) {
|
| 383 |
-
err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}"
|
| 384 |
}
|
| 385 |
|
| 386 |
if (stack.length > 1) {
|
|
|
|
| 1 |
/* =========================================================================
|
| 2 |
+
* DSSL - Domain Specific Style Language
|
| 3 |
+
* parser.js - tokenizer + parser for the braces syntax
|
| 4 |
*
|
| 5 |
* Line-oriented parser:
|
| 6 |
* - blocks open with `header {` and close with `}`
|
|
|
|
| 18 |
'placeholder-shown', 'read-only', 'read-write', 'enabled',
|
| 19 |
'indeterminate', 'first-child', 'last-child', 'only-child',
|
| 20 |
'first-of-type', 'last-of-type', 'link', 'visited',
|
| 21 |
+
'seen', // v0.4.0 - engine-native: fires once when the element enters the viewport
|
| 22 |
]);
|
| 23 |
|
| 24 |
+
// v0.5.0 - exported so the resolver can validate the `::name` half whenever a
|
| 25 |
// full selector (.card::before) carries a pseudo-element. The resolver splits
|
| 26 |
+
// those into element + pseudo and routes them into shadow CSS - previously a
|
| 27 |
// top-level `.card::before { }` compiled ok and then matched NOTHING.
|
| 28 |
export const PSEUDO_ELEMENTS = new Set([
|
| 29 |
'before', 'after', 'placeholder', 'selection', 'marker',
|
| 30 |
'first-line', 'first-letter', 'backdrop', 'file-selector-button',
|
| 31 |
+
// v0.7.2 - ::part() rides the shadow-CSS pipeline (a document stylesheet
|
| 32 |
// CAN style shadow parts of custom elements). ::slotted() is accepted by
|
| 33 |
// the parser but rejected by the resolver with a loud explanation: it only
|
| 34 |
// exists inside a shadow tree, which is not a place DSSL styles.
|
| 35 |
'part', 'slotted',
|
| 36 |
]);
|
| 37 |
|
| 38 |
+
// v0.4.2 - vendor pseudo-elements pass through verbatim (::-webkit-scrollbar
|
| 39 |
// and friends). The DOM gives you no other way to reach native scrollbars,
|
| 40 |
// so they ride the same shadow-CSS exception as ::before/::after. The name
|
| 41 |
+
// may carry trailing state (::hover, :horizontal) - it is emitted as-is.
|
| 42 |
export const VENDOR_PSEUDO = /^-(webkit|moz|ms)-/;
|
| 43 |
|
| 44 |
/** find index of first top-level occurrence of `ch` (skips strings + parens) */
|
|
|
|
| 113 |
if (c === '"' || c === "'") { q = c; out += c; continue; }
|
| 114 |
if (c === '/' && n === '/') {
|
| 115 |
// v0.7.1 bugfix: this handler used to emit its own '\n' AND rewind so
|
| 116 |
+
// the main loop emitted the same newline again - every // comment
|
| 117 |
// doubled a line, and every line number after the first comment
|
| 118 |
// drifted +1 (diagnostics pointed one line late). The newline is now
|
| 119 |
// emitted exactly once by the main loop.
|
|
|
|
| 156 |
if (h.startsWith('@container')) return { kind: 'container', query: h.slice(10).trim() };
|
| 157 |
if (h.startsWith('@signal ')) return { kind: 'signal', expr: h.slice(8).trim() };
|
| 158 |
if (h.startsWith('@')) {
|
| 159 |
+
// v0.8.0 - unknown at-rules are skipped WHOLE (balanced braces). They
|
| 160 |
// used to error and then keep parsing inside their block, so the inner
|
| 161 |
// rules leaked out unconditionally: `@supports (display:grid) { .a { … } }
|
| 162 |
+
// applied .a everywhere. The block is now dropped in its entirety -
|
| 163 |
// and raw { } is the documented escape hatch for CSS DSSL has no sugar
|
| 164 |
// for yet (@font-face, @keyframes, @supports, @import…).
|
| 165 |
+
err(line, `unknown at-rule "${h.split(' ')[0]}"`, 'the whole block is skipped - for verbatim CSS like @font-face / @keyframes / @supports, wrap it in raw { }');
|
| 166 |
return { kind: 'bogus' };
|
| 167 |
}
|
| 168 |
if (h.startsWith('::')) {
|
|
|
|
| 198 |
|
| 199 |
if (open !== -1) {
|
| 200 |
const header = rest.slice(0, open).trim();
|
| 201 |
+
// v0.7.2 - declarations and a nested block sharing one line:
|
| 202 |
// `.card { background: red; hover { ... } }`. Everything before the
|
| 203 |
// header used to be swallowed whole into it, so `background: red`
|
| 204 |
// silently became part of a selector named "background: red; hover"
|
|
|
|
| 234 |
}
|
| 235 |
};
|
| 236 |
|
| 237 |
+
/** v0.8.0 - what happens right after a block header opens.
|
| 238 |
* - raw { } (top level): capture the block body VERBATIM as node.css and
|
| 239 |
+
* return the remainder of the chunk - or enter multi-line capture mode
|
| 240 |
* (returns '' with rawCapture armed).
|
| 241 |
* - bogus (unknown at-rule / pseudo-element): skip the whole balanced
|
| 242 |
* block so its content can never leak into the parent.
|
|
|
|
| 246 |
const openBlock = (meta, node, rest, open, line) => {
|
| 247 |
if (meta.kind === 'raw') {
|
| 248 |
if (stack.length > 1) {
|
| 249 |
+
err(line, 'raw { } must be top-level - it drops verbatim CSS into the page stylesheet', 'move it out of the enclosing block');
|
| 250 |
const close = matchBrace(rest, open + 1);
|
| 251 |
return close === -1 ? '' : rest.slice(close + 1);
|
| 252 |
}
|
|
|
|
| 303 |
if (!part) continue;
|
| 304 |
const colon = scanFor(part, ':');
|
| 305 |
if (colon === -1) {
|
| 306 |
+
// `use behavior-name` - keyword declaration with a name argument
|
| 307 |
const useMatch = part.match(/^use\s+([A-Za-z][\w-]*)$/);
|
| 308 |
if (useMatch) {
|
| 309 |
stack[stack.length - 1].children.push({ kind: 'decl', prop: 'use', value: useMatch[1], line });
|
| 310 |
continue;
|
| 311 |
}
|
| 312 |
+
// valueless semantic property (e.g. `truncate`) - resolver decides if legal
|
| 313 |
if (/^[a-z][a-z0-9-]*$/i.test(part)) {
|
| 314 |
stack[stack.length - 1].children.push({ kind: 'decl', prop: part.toLowerCase(), value: '', line });
|
| 315 |
} else {
|
|
|
|
| 320 |
const prop = part.slice(0, colon).trim().toLowerCase();
|
| 321 |
const value = part.slice(colon + 1).trim();
|
| 322 |
if (!prop) { err(line, 'declaration is missing a property name'); continue; }
|
| 323 |
+
// v0.7.0 - numeric token keys. `tokens { space { 9: 96 } }` is legal:
|
| 324 |
// the built-in spacing scale stops at 8, and real pages need 9, 10, 12…
|
| 325 |
+
// Numbers are keys ONLY inside a tokens subtree - anywhere else a bare
|
| 326 |
// numeric property is a typo, and the error now says exactly that
|
| 327 |
// instead of the old "not a valid property name" shrug.
|
| 328 |
if (/^[0-9]+(\.[0-9]+)?$/.test(prop)) {
|
|
|
|
| 331 |
continue;
|
| 332 |
}
|
| 333 |
err(line, `"${prop}" is not a valid property name`,
|
| 334 |
+
'numeric names are token keys only inside tokens { } - e.g. tokens { space { 9: 96 } } - and then referenced as space.9; property names must start with a letter');
|
| 335 |
continue;
|
| 336 |
}
|
| 337 |
+
// v0.4.0: dotted custom properties are legal - `--color.accent: …` is
|
| 338 |
// scoped-token sugar (resolver maps it to the engine's token variable)
|
| 339 |
if (!/^(-{1,2}[a-z][a-z0-9.-]*|[a-z][a-z0-9-]*)$/i.test(prop)) {
|
| 340 |
err(line, `"${prop}" is not a valid property name`,
|
|
|
|
| 380 |
});
|
| 381 |
if (buf.trim()) processChunk(buf, bufLine);
|
| 382 |
if (rawCapture) {
|
| 383 |
+
err(lines.length, 'the raw { } block was never closed with "}"', 'every "{" needs a matching "}" - the CSS captured so far is discarded');
|
| 384 |
}
|
| 385 |
|
| 386 |
if (stack.length > 1) {
|
dssl-vscode/lib/engine/resolver.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
/* =========================================================================
|
| 2 |
-
* DSSL
|
| 3 |
* Walks the parsed tree and produces flat, engine-ready rules:
|
| 4 |
*
|
| 5 |
* rule = {
|
|
@@ -7,7 +7,7 @@
|
|
| 7 |
* media: [query], // all must match
|
| 8 |
* signals: [{name, op, value}], // JS-driven conditions
|
| 9 |
* containers: [query], // container-query conditions
|
| 10 |
-
* layer, order, // cascade: (layer, order)
|
| 11 |
* decls: [{prop, value} | special],
|
| 12 |
* file, line
|
| 13 |
* }
|
|
@@ -18,7 +18,13 @@
|
|
| 18 |
* ========================================================================= */
|
| 19 |
|
| 20 |
import { levenshtein, didYouMean, composeDeform } from './utils.js';
|
| 21 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 23 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
| 24 |
|
|
@@ -45,7 +51,7 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
|
|
| 45 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 46 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 47 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
| 48 |
-
// v0.8.0
|
| 49 |
// `background: url(a.png) no-repeat` was a false error because "no-repeat"
|
| 50 |
// was missing while "repeat" existed
|
| 51 |
'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
|
|
@@ -63,12 +69,12 @@ export const CSS_KEYWORDS = new Set(('inherit,initial,unset,revert,revert-layer,
|
|
| 63 |
'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
|
| 64 |
'-webkit-box,-webkit-inline-box,' +
|
| 65 |
'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
|
| 66 |
-
// v0.5.0
|
| 67 |
// the property existed; plus the rendering keywords people actually type.
|
| 68 |
'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
|
| 69 |
'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
|
| 70 |
|
| 71 |
-
const UNITLESS_PROPS = new Set([
|
| 72 |
'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
|
| 73 |
'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
|
| 74 |
'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
|
|
@@ -77,20 +83,20 @@ const UNITLESS_PROPS = new Set([
|
|
| 77 |
'grid-row', 'grid-column', 'grid-area',
|
| 78 |
]);
|
| 79 |
|
| 80 |
-
// v0.7.2
|
| 81 |
// never unitless (both used to emit values the browser silently rejected).
|
| 82 |
// transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
|
| 83 |
// why `transition-delay: 200` shipped as invalid unitless 200.
|
| 84 |
const TIME_PROPS = new Set([
|
| 85 |
'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
|
| 86 |
-
// v0.8.0
|
| 87 |
// `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
|
| 88 |
// to become "ghost 400px" (invalid). 400 → 400ms, matching animate:.
|
| 89 |
'animation',
|
| 90 |
]);
|
| 91 |
|
| 92 |
// A practical whitelist of standard CSS properties (loud errors beat silent ones)
|
| 93 |
-
const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
|
| 94 |
'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
|
| 95 |
'block-size,border,border-block,border-block-color,border-block-end,border-block-end-color,border-block-end-style,border-block-end-width,border-block-start,border-block-start-color,border-block-start-style,border-block-start-width,border-block-style,border-block-width,' +
|
| 96 |
'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' +
|
|
@@ -129,7 +135,7 @@ export const DEFAULT_TOKENS = {
|
|
| 129 |
'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
| 130 |
};
|
| 131 |
|
| 132 |
-
const EASINGS = {
|
| 133 |
snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)',
|
| 134 |
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
| 135 |
bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
@@ -153,7 +159,7 @@ const PLACE_WORDS = {
|
|
| 153 |
|
| 154 |
const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/;
|
| 155 |
|
| 156 |
-
// functions whose numeric arguments are NOT lengths
|
| 157 |
const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' +
|
| 158 |
'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' +
|
| 159 |
'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(','));
|
|
@@ -166,6 +172,19 @@ const LENGTH_FN = new Set('calc,min,max,clamp,round,mod,rem,abs,sign,hypot,pow,s
|
|
| 166 |
* - token references become var(--dssl-…), `token/NN` becomes color-mix
|
| 167 |
* - unknown identifiers are errors (with did-you-mean)
|
| 168 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
function resolveValue(raw, ctx) {
|
| 170 |
let out = '';
|
| 171 |
let i = 0;
|
|
@@ -210,7 +229,7 @@ function resolveValue(raw, ctx) {
|
|
| 210 |
|
| 211 |
if (ident.startsWith('--')) { out += ident; continue; } // custom property
|
| 212 |
|
| 213 |
-
// function call
|
| 214 |
if (raw[i] === '(') {
|
| 215 |
let depth = 1, j = i + 1;
|
| 216 |
while (j < n && depth > 0) {
|
|
@@ -224,7 +243,7 @@ function resolveValue(raw, ctx) {
|
|
| 224 |
}
|
| 225 |
const inner = raw.slice(i + 1, j - 1);
|
| 226 |
const fname = ident.toLowerCase();
|
| 227 |
-
// v0.8.0
|
| 228 |
// so `url(a.png)` raised "unknown value a.png" (quoted urls worked
|
| 229 |
// only because strings were already consumed whole). CSS allows the
|
| 230 |
// unquoted form; DSSL passes it through untouched now.
|
|
@@ -233,7 +252,7 @@ function resolveValue(raw, ctx) {
|
|
| 233 |
i = j;
|
| 234 |
continue;
|
| 235 |
}
|
| 236 |
-
// v0.3.2 fix: repeat(COUNT, tracks)
|
| 237 |
// never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
|
| 238 |
// which the browser rejected and the whole grid silently collapsed.
|
| 239 |
if (fname === 'repeat') {
|
|
@@ -243,7 +262,7 @@ function resolveValue(raw, ctx) {
|
|
| 243 |
i = j;
|
| 244 |
continue;
|
| 245 |
}
|
| 246 |
-
// auto-fill / auto-fit / nested weirdness
|
| 247 |
}
|
| 248 |
// color-ish functions keep bare numbers bare; calc-family gets px
|
| 249 |
const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
|
|
@@ -274,7 +293,7 @@ function resolveValue(raw, ctx) {
|
|
| 274 |
continue;
|
| 275 |
}
|
| 276 |
|
| 277 |
-
// v0.5.0
|
| 278 |
// In font-family values and font.* tokens, bare multi-word names pass
|
| 279 |
// through instead of demanding quotes. Everywhere else stays loud.
|
| 280 |
if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
|
|
@@ -295,8 +314,8 @@ function resolveValue(raw, ctx) {
|
|
| 295 |
const hex = raw.slice(i, j);
|
| 296 |
out += hex;
|
| 297 |
i = j;
|
| 298 |
-
// v0.7.2
|
| 299 |
-
// #ff0000/50px (the bare number got the generic px treatment)
|
| 300 |
// CSS, silently dropped by the browser. Tokens always had /NN via
|
| 301 |
// color-mix; raw hex now gets the same treatment.
|
| 302 |
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
|
@@ -312,7 +331,7 @@ function resolveValue(raw, ctx) {
|
|
| 312 |
return out;
|
| 313 |
}
|
| 314 |
|
| 315 |
-
/** paren-aware split on the `->` arrow
|
| 316 |
function splitArrow(s) {
|
| 317 |
const out = [];
|
| 318 |
let cur = '', depth = 0, q = null;
|
|
@@ -329,7 +348,7 @@ function splitArrow(s) {
|
|
| 329 |
return out;
|
| 330 |
}
|
| 331 |
|
| 332 |
-
/** split on a top-level separator (parens-aware)
|
| 333 |
function topSplit(str, sep) {
|
| 334 |
const out = [];
|
| 335 |
let cur = '', depth = 0;
|
|
@@ -345,12 +364,12 @@ function topSplit(str, sep) {
|
|
| 345 |
|
| 346 |
function findToken(tokens, ident) {
|
| 347 |
if (tokens.has(ident)) return ident;
|
| 348 |
-
// try progressive dot paths (a.b.c defined, user typed a.b.c
|
| 349 |
if (ident.includes('.')) {
|
| 350 |
const parts = ident.split('.');
|
| 351 |
for (let k = parts.length - 1; k > 0; k--) {
|
| 352 |
const key = parts.slice(0, k).join('.');
|
| 353 |
-
if (tokens.has(key)) return key; // hmm
|
| 354 |
}
|
| 355 |
}
|
| 356 |
return null;
|
|
@@ -363,7 +382,7 @@ function findToken(tokens, ident) {
|
|
| 363 |
const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false });
|
| 364 |
const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true });
|
| 365 |
|
| 366 |
-
// v0.6.0
|
| 367 |
// angles and `20deg` is accepted for people with CSS muscle memory.
|
| 368 |
const degNum = (raw, what, ctx) => {
|
| 369 |
const t = String(raw).trim();
|
|
@@ -372,16 +391,16 @@ const degNum = (raw, what, ctx) => {
|
|
| 372 |
ctx.errors.push({
|
| 373 |
file: ctx.file, line: ctx.line,
|
| 374 |
message: `${what} takes a bare number of degrees`,
|
| 375 |
-
hint: `try ${what}: 20 or ${what}: -12
|
| 376 |
});
|
| 377 |
return null;
|
| 378 |
}
|
| 379 |
return parseFloat(m[1]);
|
| 380 |
};
|
| 381 |
|
| 382 |
-
/** v0.6.0
|
| 383 |
* Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`);
|
| 384 |
-
* the prefix is whatever the grammar calls direction/shape
|
| 385 |
// Stops are colors by construction, so a bare word that is not a color name is
|
| 386 |
// a typo the browser would only mutter about at apply time. Say it NOW instead.
|
| 387 |
const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
|
|
@@ -431,7 +450,7 @@ function buildGradient(v, ctx) {
|
|
| 431 |
ctx.errors.push({
|
| 432 |
file: ctx.file, line: ctx.line,
|
| 433 |
message: `gradient needs at least two stops`,
|
| 434 |
-
hint: 'try gradient: linear 135, #ff0055 -> #00d4ff
|
| 435 |
});
|
| 436 |
return null;
|
| 437 |
}
|
|
@@ -472,7 +491,7 @@ function buildGradient(v, ctx) {
|
|
| 472 |
ctx.errors.push({
|
| 473 |
file: ctx.file, line: ctx.line,
|
| 474 |
message: `"${head}" is not a conic gradient prefix`,
|
| 475 |
-
hint: 'use from <angle> and/or at <position>
|
| 476 |
});
|
| 477 |
return null;
|
| 478 |
}
|
|
@@ -504,9 +523,9 @@ function buildGradient(v, ctx) {
|
|
| 504 |
|
| 505 |
function parseFlexWords(value, ctx, dir) {
|
| 506 |
const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
|
| 507 |
-
// v0.8.0
|
| 508 |
// `row: gap 8 between center` (the guide's own example) compiled to
|
| 509 |
-
// justify-content: space-between followed by justify-content: center
|
| 510 |
// and center won, bunching everything in the middle. Words now declare
|
| 511 |
// the axis they are actually about: distribution words (between/around/
|
| 512 |
// evenly) own the main axis, stretch/baseline own the cross axis, and
|
|
@@ -537,8 +556,8 @@ function parseFlexWords(value, ctx, dir) {
|
|
| 537 |
const axisWarn = (prop, prev) => {
|
| 538 |
ctx.warnings = ctx.warnings || [];
|
| 539 |
ctx.warnings.push(prev === a
|
| 540 |
-
? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir}
|
| 541 |
-
: { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win
|
| 542 |
};
|
| 543 |
let wrote = false;
|
| 544 |
if (role === 'main' || (role === 'both' && !mainSet)) {
|
|
@@ -556,9 +575,9 @@ function parseFlexWords(value, ctx, dir) {
|
|
| 556 |
ctx.warnings.push({
|
| 557 |
file: ctx.file, line: ctx.line,
|
| 558 |
message: mainSet === a || crossSet === a
|
| 559 |
-
? `"${a}" is written twice in ${dir}
|
| 560 |
: `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
|
| 561 |
-
hint: 'one distribution word + one alignment word is the whole grammar
|
| 562 |
});
|
| 563 |
}
|
| 564 |
continue;
|
|
@@ -568,7 +587,7 @@ function parseFlexWords(value, ctx, dir) {
|
|
| 568 |
return out;
|
| 569 |
}
|
| 570 |
|
| 571 |
-
// v0.6.0
|
| 572 |
// `transition: 200 snap bend` should just work and mean transform.
|
| 573 |
const DEFORM_TO_TRANSFORM = {
|
| 574 |
bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform',
|
|
@@ -586,7 +605,7 @@ const SEMANTIC = {
|
|
| 586 |
column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
|
| 587 |
grid(v, ctx) {
|
| 588 |
const atoms = v.split(/\s+/).filter(Boolean);
|
| 589 |
-
// v0.7.2
|
| 590 |
// atoms were fed into grid-template-columns → "3px gap 12px", invalid.
|
| 591 |
// gap and rows are now first-class words of the shorthand.
|
| 592 |
let gap = null, rows = null;
|
|
@@ -598,7 +617,7 @@ const SEMANTIC = {
|
|
| 598 |
}
|
| 599 |
let cols;
|
| 600 |
if (!rest.length) {
|
| 601 |
-
ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 · grid: auto-fill 200 · grid: 200px 1fr
|
| 602 |
cols = 'none';
|
| 603 |
} else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
|
| 604 |
cols = `repeat(${rest[0]}, 1fr)`;
|
|
@@ -629,7 +648,7 @@ const SEMANTIC = {
|
|
| 629 |
}
|
| 630 |
return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }];
|
| 631 |
},
|
| 632 |
-
// v0.3.4
|
| 633 |
// thing" is the first question everyone asks. Emits the alignment pair only;
|
| 634 |
// the ENGINE guarantees a formatting context for alignment declarations
|
| 635 |
// (inline elements → inline-grid, blocks → grid), so `center:` can never
|
|
@@ -716,7 +735,7 @@ const SEMANTIC = {
|
|
| 716 |
{ prop: 'white-space', value: 'nowrap' },
|
| 717 |
];
|
| 718 |
},
|
| 719 |
-
// v0.4.0
|
| 720 |
// screen readers. One word instead of nine lines of clip folklore.
|
| 721 |
'visually-hidden'() {
|
| 722 |
return [
|
|
@@ -731,7 +750,7 @@ const SEMANTIC = {
|
|
| 731 |
{ prop: 'border', value: '0' },
|
| 732 |
];
|
| 733 |
},
|
| 734 |
-
// v0.4.0
|
| 735 |
// duration [easing] [property] (property omitted → all)
|
| 736 |
// The one-value form keeps working exactly as before:
|
| 737 |
// transition: 200 snap
|
|
@@ -745,7 +764,7 @@ const SEMANTIC = {
|
|
| 745 |
const atoms = part.split(/\s+/);
|
| 746 |
const dur = atoms[0];
|
| 747 |
if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
|
| 748 |
-
// v0.8.0
|
| 749 |
// ease`). That used to be a generic "not a time" error and the part
|
| 750 |
// silently dropped. The error now REWRITES the value into the DSSL
|
| 751 |
// order (time · easing · property) so the fix is a copy-paste.
|
|
@@ -757,11 +776,11 @@ const SEMANTIC = {
|
|
| 757 |
ctx.errors.push({
|
| 758 |
file: ctx.file, line: ctx.line,
|
| 759 |
message: `transition: the CSS form (property first) is not DSSL`,
|
| 760 |
-
hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''}
|
| 761 |
});
|
| 762 |
continue;
|
| 763 |
}
|
| 764 |
-
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration · easing · property
|
| 765 |
continue;
|
| 766 |
}
|
| 767 |
const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur;
|
|
@@ -801,15 +820,15 @@ const SEMANTIC = {
|
|
| 801 |
}
|
| 802 |
if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
|
| 803 |
if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
|
| 804 |
-
// v0.5.0
|
| 805 |
-
// animate: pulse 1600 infinite alternate
|
| 806 |
if (a === 'infinite') { spec.iterations = Infinity; continue; }
|
| 807 |
if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
|
| 808 |
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate'])) });
|
| 809 |
}
|
| 810 |
return [{ prop: '@animate', value: spec }];
|
| 811 |
},
|
| 812 |
-
// v0.4.0
|
| 813 |
// child's animation delay by its DOM position. Pairs with `seen` + `animate`
|
| 814 |
// on the children: grids and lists cascade in as they scroll into view.
|
| 815 |
stagger(v, ctx) {
|
|
@@ -822,7 +841,7 @@ const SEMANTIC = {
|
|
| 822 |
const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t);
|
| 823 |
return [{ prop: '@stagger', value: ms }];
|
| 824 |
},
|
| 825 |
-
// v0.5.0
|
| 826 |
// nothing; DSSL refuses. `deny` makes the element always yield out of any
|
| 827 |
// overlap; `auto` yields only while the element sits below 100% opacity.
|
| 828 |
// Both inherit down the tree, so `body { overlap: auto }` is the site-wide
|
|
@@ -842,7 +861,7 @@ const SEMANTIC = {
|
|
| 842 |
},
|
| 843 |
|
| 844 |
/* ---------------------------------------------------------------- *
|
| 845 |
-
* v0.6.0
|
| 846 |
* bend-around. All are engine-native (@-props) that compose into the
|
| 847 |
* transform channel, so `transition: 200 snap transform` tweens them
|
| 848 |
* and `:active { bend: 14 }` makes a button visibly flex under the
|
|
@@ -859,7 +878,7 @@ const SEMANTIC = {
|
|
| 859 |
if (Math.abs(n) > 170) {
|
| 860 |
ctx.errors.push({
|
| 861 |
file: ctx.file, line: ctx.line,
|
| 862 |
-
message: `bend-around caps at ±170°
|
| 863 |
hint: 'try 30..120 for a visible arc',
|
| 864 |
});
|
| 865 |
return [];
|
|
@@ -882,7 +901,7 @@ const SEMANTIC = {
|
|
| 882 |
ctx.errors.push({
|
| 883 |
file: ctx.file, line: ctx.line,
|
| 884 |
message: `stretch: ${m[1]} would flatten the element to nothing`,
|
| 885 |
-
hint: 'use a factor above 0
|
| 886 |
});
|
| 887 |
return [];
|
| 888 |
}
|
|
@@ -891,11 +910,11 @@ const SEMANTIC = {
|
|
| 891 |
return [{ prop: '@stretch', value: { factor: f, axis } }];
|
| 892 |
},
|
| 893 |
|
| 894 |
-
/* v0.6.0
|
| 895 |
* kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes
|
| 896 |
* (ink/40) and carry a trailing % position. gradient-text clips the same
|
| 897 |
* gradient into the glyphs. gradient-flow loops background-position so a
|
| 898 |
-
* gradient drifts forever
|
| 899 |
gradient(v, ctx) {
|
| 900 |
const img = buildGradient(v, ctx);
|
| 901 |
return img ? [{ prop: 'background-image', value: img }] : [];
|
|
@@ -940,10 +959,10 @@ const SEMANTIC = {
|
|
| 940 |
},
|
| 941 |
};
|
| 942 |
|
| 943 |
-
/* v0.8.0
|
| 944 |
* and now any length-ish property takes it inline: `font-size: fluid 16 24`.
|
| 945 |
* Both compile to a clamp() that scales linearly between the viewport bounds
|
| 946 |
-
* (360px and 1200px by default)
|
| 947 |
* gets wrong. */
|
| 948 |
const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
|
| 949 |
'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left,' +
|
|
@@ -989,14 +1008,59 @@ function buildFluidParts(parts, ctx, prop = 'font-size') {
|
|
| 989 |
}
|
| 990 |
|
| 991 |
/* ------------------------------------------------------------------ *
|
| 992 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 993 |
* ------------------------------------------------------------------ */
|
| 994 |
|
| 995 |
function resolveDecl(prop, value, ctx) {
|
| 996 |
const errors = ctx.errors;
|
| 997 |
const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
|
| 998 |
|
| 999 |
-
// v0.8.0
|
| 1000 |
// value: the compiler said "unknown value important" AND still emitted
|
| 1001 |
// `red !important`, which the browser then silently dropped as an invalid
|
| 1002 |
// inline value. Now: one loud error, nothing emitted, and the pin { }
|
|
@@ -1004,14 +1068,14 @@ function resolveDecl(prop, value, ctx) {
|
|
| 1004 |
if (hasImportant(value)) {
|
| 1005 |
errors.push({
|
| 1006 |
file: ctx.file, line: ctx.line,
|
| 1007 |
-
message: `!important does not exist in DSSL
|
| 1008 |
-
hint: 'the cascade is (layer, order) and later wins
|
| 1009 |
});
|
| 1010 |
return [];
|
| 1011 |
}
|
| 1012 |
|
| 1013 |
if (prop.startsWith('--')) {
|
| 1014 |
-
// v0.4.0
|
| 1015 |
// .danger { --color.accent: color.err }
|
| 1016 |
// sets --dssl-color-accent ON the element, and custom properties inherit,
|
| 1017 |
// so every var() reference in the subtree re-resolves. Plain names
|
|
@@ -1025,7 +1089,7 @@ function resolveDecl(prop, value, ctx) {
|
|
| 1025 |
const sem = SEMANTIC[prop];
|
| 1026 |
if (sem) return sem(value, ctx2);
|
| 1027 |
|
| 1028 |
-
// v0.8.0
|
| 1029 |
// bare motion names must not pay the generic unknown-value tax: "spin"
|
| 1030 |
// used to error with the surreal suggestion "did you mean span?". Motion
|
| 1031 |
// names pass silently here and a dedicated warning in the final loop says
|
|
@@ -1034,7 +1098,7 @@ function resolveDecl(prop, value, ctx) {
|
|
| 1034 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
-
// v0.8.0
|
| 1038 |
// font-size: fluid 16 24 → clamp(16px, calc(16px + 0.952vw), 24px)
|
| 1039 |
const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
|
| 1040 |
if (fluidM && FLUID_PROPS.has(prop)) {
|
|
@@ -1052,7 +1116,7 @@ function resolveDecl(prop, value, ctx) {
|
|
| 1052 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
|
| 1053 |
}
|
| 1054 |
|
| 1055 |
-
/** string-aware !important scan
|
| 1056 |
function hasImportant(v) {
|
| 1057 |
const s = String(v);
|
| 1058 |
let q = null;
|
|
@@ -1115,11 +1179,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1115 |
const errors = [];
|
| 1116 |
const rules = [];
|
| 1117 |
const shadowRules = [];
|
| 1118 |
-
const raws = []; // v0.8.0
|
| 1119 |
const motions = {};
|
| 1120 |
const behaviors = {};
|
| 1121 |
-
// v0.7.0
|
| 1122 |
-
// probably not what the author meant. Separate from errors
|
| 1123 |
// but the engine prints them loudly so nobody debugs cascade order by hand
|
| 1124 |
// ever again.
|
| 1125 |
const warnings = [];
|
|
@@ -1155,7 +1219,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1155 |
} else if (node.kind === 'motion') {
|
| 1156 |
motions[node.name] = node;
|
| 1157 |
} else {
|
| 1158 |
-
// v0.8.0
|
| 1159 |
// and take the whole stylesheet down (the @font-face crash report).
|
| 1160 |
// The parser now skips unknown at-rules whole, but the guard stays.
|
| 1161 |
for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
|
|
@@ -1194,7 +1258,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1194 |
continue;
|
| 1195 |
}
|
| 1196 |
const props = {};
|
| 1197 |
-
const deform = {}; // v0.6.0
|
| 1198 |
const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' };
|
| 1199 |
for (const d of child.children) {
|
| 1200 |
if (d.kind !== 'decl') continue;
|
|
@@ -1216,7 +1280,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1216 |
deform.fold !== undefined || deform.bendUp !== undefined) {
|
| 1217 |
// no box exists at resolve time, so edge-pinned members ride a
|
| 1218 |
// transformOrigin keyframe property instead of baked translates.
|
| 1219 |
-
// Presence (not truthiness)
|
| 1220 |
const c = composeDeform(deform, props.transform || '', null);
|
| 1221 |
props.transform = c.transform;
|
| 1222 |
if (c.origin) props.transformOrigin = c.origin;
|
|
@@ -1238,7 +1302,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1238 |
for (const key of keys) {
|
| 1239 |
const raw = tokenMap.get(key);
|
| 1240 |
const prop = '--dssl-' + key.replace(/\./g, '-');
|
| 1241 |
-
// font.* tokens hold font stacks
|
| 1242 |
decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
|
| 1243 |
}
|
| 1244 |
if (!decls.length) return null;
|
|
@@ -1257,11 +1321,11 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1257 |
/* ---- main recursive walk ---- */
|
| 1258 |
|
| 1259 |
const walk = (children, ctx, ctxRule) => {
|
| 1260 |
-
// v0.7.2
|
| 1261 |
// block (hover { }, @media, a nested selector) was emitted BEFORE the
|
| 1262 |
// parent's own declarations got their cascade order, so `background: red`
|
| 1263 |
// on the parent always beat `hover { background: red }` nested inside it
|
| 1264 |
-
//
|
| 1265 |
// finding. (2) media/container/signal/layer blocks flushed with the
|
| 1266 |
// PARENT context, dropping the condition or layer entirely: a nested
|
| 1267 |
// `@media dark` was silently unconditional AND sorted before base.
|
|
@@ -1291,7 +1355,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1291 |
}
|
| 1292 |
const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
|
| 1293 |
for (const r of resolved) {
|
| 1294 |
-
// v0.7.0
|
| 1295 |
// so the ordering linter can tell an explicit "display:" from a
|
| 1296 |
// shorthand that expanded into one (column: → display: flex).
|
| 1297 |
if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop;
|
|
@@ -1302,7 +1366,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1302 |
case 'selector': {
|
| 1303 |
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
|
| 1304 |
const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
|
| 1305 |
-
if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}"
|
| 1306 |
}
|
| 1307 |
flushPending(node.line);
|
| 1308 |
const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
|
|
@@ -1319,14 +1383,14 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1319 |
}
|
| 1320 |
flushPending(node.line);
|
| 1321 |
const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
|
| 1322 |
-
// v0.7.2
|
| 1323 |
// block it compiled into an inline style the browser rejects, so the
|
| 1324 |
// element just sat at its base style forever. Say so, loudly, and
|
| 1325 |
// point at the two real patterns (final value, or a motion).
|
| 1326 |
if (node.name === 'seen') {
|
| 1327 |
for (const d of node.children) {
|
| 1328 |
if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
|
| 1329 |
-
errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}"
|
| 1330 |
}
|
| 1331 |
}
|
| 1332 |
}
|
|
@@ -1383,13 +1447,13 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1383 |
if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
|
| 1384 |
if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
|
| 1385 |
const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
|
| 1386 |
-
// v0.5.0
|
| 1387 |
// there is no DOM handle to nudge, animate, stack or stagger. They
|
| 1388 |
// used to leak into the shadow sheet as junk CSS the browser
|
| 1389 |
-
// silently dropped
|
| 1390 |
for (const r of resolved) {
|
| 1391 |
if (r.prop.startsWith('@')) {
|
| 1392 |
-
errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name}
|
| 1393 |
continue;
|
| 1394 |
}
|
| 1395 |
srule.decls.push({ ...r, line: d.line });
|
|
@@ -1409,7 +1473,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1409 |
break;
|
| 1410 |
}
|
| 1411 |
case 'raw': {
|
| 1412 |
-
// v0.8.0
|
| 1413 |
// @supports, @import, @property…). The parser captured the body
|
| 1414 |
// untouched; it rides the same document stylesheet as the
|
| 1415 |
// pseudo-element rules and survives `dssl build` verbatim.
|
|
@@ -1445,23 +1509,23 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1445 |
function flush(sub, ctx, line) {
|
| 1446 |
if (!sub.decls.length) return;
|
| 1447 |
if (!ctx.sel) {
|
| 1448 |
-
errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media…
|
| 1449 |
return;
|
| 1450 |
}
|
| 1451 |
-
// v0.5.0
|
| 1452 |
// used to compile ok and then match NOTHING: inline styles can never reach
|
| 1453 |
// pseudo-elements. Now the selector is split into element + pseudo halves
|
| 1454 |
-
// and the rule rides the shadow-CSS pipeline
|
| 1455 |
// form (`.card { ::before { } }`) always did. Both forms are now one thing.
|
| 1456 |
const ps = analyzePseudo(ctx.sel);
|
| 1457 |
if (ps.mode === 'all') {
|
| 1458 |
// parity with the nested path: bare `content:` becomes content: ""
|
| 1459 |
-
// v0.5.0
|
| 1460 |
// nested pseudo path): no DOM handle means no nudge, animate or stack.
|
| 1461 |
const decls = [];
|
| 1462 |
for (const d of sub.decls) {
|
| 1463 |
if (d.prop && d.prop.startsWith('@')) {
|
| 1464 |
-
errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]}
|
| 1465 |
continue;
|
| 1466 |
}
|
| 1467 |
decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
|
|
@@ -1480,7 +1544,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1480 |
errors.push({
|
| 1481 |
file, line,
|
| 1482 |
message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
|
| 1483 |
-
hint: 'style the slotted element itself with a normal selector
|
| 1484 |
});
|
| 1485 |
continue;
|
| 1486 |
}
|
|
@@ -1492,7 +1556,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1492 |
});
|
| 1493 |
continue;
|
| 1494 |
}
|
| 1495 |
-
// v0.5.0
|
| 1496 |
// rejected (engine-native props, bad values), emit NOTHING rather
|
| 1497 |
// than an empty ghost rule that matches and styles zero things.
|
| 1498 |
if (!decls.length) continue;
|
|
@@ -1505,9 +1569,25 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1505 |
return;
|
| 1506 |
}
|
| 1507 |
if (ps.mode === 'mixed') {
|
| 1508 |
-
errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules
|
| 1509 |
return;
|
| 1510 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
warnDisplayClash(sub.decls);
|
| 1512 |
rules.push({
|
| 1513 |
id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
|
|
@@ -1517,15 +1597,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1517 |
});
|
| 1518 |
}
|
| 1519 |
|
| 1520 |
-
/* v0.7.0
|
| 1521 |
* whole cascade model. But "column: gap 2" quietly expanding into
|
| 1522 |
* display: flex after an explicit "display: none" reads like a menu
|
| 1523 |
-
* toggle and behaves like a bug report
|
| 1524 |
* to exactly this ("their own source comments warn about this, but it
|
| 1525 |
* should be a compiler warning, not a comment"). Agreed. When a
|
| 1526 |
* shorthand's expansion clobbers an explicit display written earlier in
|
| 1527 |
* the SAME rule, say so at compile time. Cross-rule overrides are the
|
| 1528 |
-
* documented cascade at work, not an accident
|
| 1529 |
function warnDisplayClash(decls) {
|
| 1530 |
let explicit = null;
|
| 1531 |
for (const d of decls) {
|
|
@@ -1534,7 +1614,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1534 |
if (explicit && explicit.value !== d.value && warnings.length < 400) {
|
| 1535 |
warnings.push({
|
| 1536 |
file, line: d.line,
|
| 1537 |
-
message: `"${d.via}:" expands to display: ${d.value}, which overrides the earlier "display: ${explicit.value}" (line ${explicit.line}) in the same rule
|
| 1538 |
hint: `if the override is intended, delete the dead line; otherwise put "${d.via}:" first, or move the display you want to win AFTER it`,
|
| 1539 |
});
|
| 1540 |
}
|
|
@@ -1557,7 +1637,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1557 |
errors.push({
|
| 1558 |
file, line: d.line,
|
| 1559 |
message: `unknown motion "${d.value.motion}"`,
|
| 1560 |
-
hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } }
|
| 1561 |
});
|
| 1562 |
d.value.broken = true;
|
| 1563 |
} else {
|
|
@@ -1565,7 +1645,7 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1565 |
d.value.essential = m.essential;
|
| 1566 |
}
|
| 1567 |
}
|
| 1568 |
-
// v0.8.0
|
| 1569 |
// name that matches nothing used to be a SILENT no-op (no @keyframes
|
| 1570 |
// can exist in DSSL). Warn with the whole story; if the keyframes come
|
| 1571 |
// from raw { } or another stylesheet, the warning says it can be ignored.
|
|
@@ -1575,15 +1655,15 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1575 |
if (words.length && !named) {
|
| 1576 |
warnings.push({
|
| 1577 |
file, line: d.line,
|
| 1578 |
-
message: `animation "${d.value}" references no motion defined in this sheet
|
| 1579 |
-
hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400
|
| 1580 |
});
|
| 1581 |
}
|
| 1582 |
}
|
| 1583 |
}
|
| 1584 |
}
|
| 1585 |
|
| 1586 |
-
// v0.8.0
|
| 1587 |
// emitter can turn the whole motion vocabulary into real @keyframes.
|
| 1588 |
const motionFrames = {};
|
| 1589 |
for (const name of Object.keys(motions)) {
|
|
@@ -1591,6 +1671,9 @@ export function resolve(ast, file = 'inline.dssl', baseTokens = null) {
|
|
| 1591 |
if (m && m.frames.length) motionFrames[name] = m;
|
| 1592 |
}
|
| 1593 |
|
|
|
|
|
|
|
|
|
|
| 1594 |
const mediaQueries = new Set();
|
| 1595 |
for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
|
| 1596 |
|
|
@@ -1603,10 +1686,10 @@ function combine(parent, child) {
|
|
| 1603 |
return parent + ' ' + child;
|
| 1604 |
}
|
| 1605 |
|
| 1606 |
-
/* v0.5.0
|
| 1607 |
-
* 'none'
|
| 1608 |
-
* 'all'
|
| 1609 |
-
* 'mixed'
|
| 1610 |
* The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
|
| 1611 |
function analyzePseudo(sel) {
|
| 1612 |
// top-level comma split
|
|
|
|
| 1 |
/* =========================================================================
|
| 2 |
+
* DSSL - resolver.js
|
| 3 |
* Walks the parsed tree and produces flat, engine-ready rules:
|
| 4 |
*
|
| 5 |
* rule = {
|
|
|
|
| 7 |
* media: [query], // all must match
|
| 8 |
* signals: [{name, op, value}], // JS-driven conditions
|
| 9 |
* containers: [query], // container-query conditions
|
| 10 |
+
* layer, order, // cascade: (layer, order) - no specificity
|
| 11 |
* decls: [{prop, value} | special],
|
| 12 |
* file, line
|
| 13 |
* }
|
|
|
|
| 18 |
* ========================================================================= */
|
| 19 |
|
| 20 |
import { levenshtein, didYouMean, composeDeform } from './utils.js';
|
| 21 |
+
// 0.9.0: the three new styling families. Each is a pure module, so the compiler
|
| 22 |
+
// can validate their words (with did-you-mean) without a DOM.
|
| 23 |
+
import { parseTexture } from './texture.js';
|
| 24 |
+
import { parseAura } from './aura.js';
|
| 25 |
+
import { parseSlider, sliderCss } from './slider.js';
|
| 26 |
+
import { lintRules } from './lint.js';
|
| 27 |
+
// v0.5.0 - the resolver validates the `::name` half of full selectors so a
|
| 28 |
// typo like .card::befor is a LOUD error, never a silently dead rule.
|
| 29 |
import { PSEUDO_ELEMENTS, VENDOR_PSEUDO, STATE_WORDS } from './parser.js';
|
| 30 |
|
|
|
|
| 51 |
'serif,sans-serif,monospace,cursive,fantasy,system-ui,ui-sans-serif,ui-monospace,ui-serif,ui-rounded,' +
|
| 52 |
'system-ui,-apple-system,Roboto,Helvetica,Arial,Menlo,Consolas,SFMono-Regular,Liberation-Mono,Inter,Noto-Sans,Ubuntu,Cantarell,Oxygen,Fira-Sans,Droid-Sans,' +
|
| 53 |
'border-box,content-box,padding-box,fill-box,stroke-box,view-box,' +
|
| 54 |
+
// v0.8.0 - the background repeat/attachment words the url() fix exposed:
|
| 55 |
// `background: url(a.png) no-repeat` was a false error because "no-repeat"
|
| 56 |
// was missing while "repeat" existed
|
| 57 |
'no-repeat,repeat-x,repeat-y,space,round,scroll,local,' +
|
|
|
|
| 69 |
'isolate,plaintext,sideways,upright,mixed,ltr,rtl,transform,auto,' +
|
| 70 |
'-webkit-box,-webkit-inline-box,' +
|
| 71 |
'thin,thick,medium,separate,collapse,vertical,horizontal,balance,pretty,orphans,' +
|
| 72 |
+
// v0.5.0 - hyphens: manual used to be rejected ("unknown value") even though
|
| 73 |
// the property existed; plus the rendering keywords people actually type.
|
| 74 |
'manual,pixelated,crisp-edges,optimizeSpeed,optimizeLegibility,geometricPrecision,' +
|
| 75 |
'flex-start,flex-end,self-start,self-end,snap-block,snap-inline').split(','));
|
| 76 |
|
| 77 |
+
export const UNITLESS_PROPS = new Set([
|
| 78 |
'opacity', 'line-height', 'z-index', 'font-weight', 'flex-grow', 'flex-shrink',
|
| 79 |
'flex', 'order', 'scale', 'widows', 'orphans', 'column-count', 'aspect-ratio',
|
| 80 |
'animation-iteration-count', 'flood-opacity', 'fill-opacity', 'stop-opacity',
|
|
|
|
| 83 |
'grid-row', 'grid-column', 'grid-area',
|
| 84 |
]);
|
| 85 |
|
| 86 |
+
// v0.7.2 - time properties: a bare number is milliseconds, never px, and
|
| 87 |
// never unitless (both used to emit values the browser silently rejected).
|
| 88 |
// transition-delay/animation-delay used to sit in UNITLESS_PROPS, which is
|
| 89 |
// why `transition-delay: 200` shipped as invalid unitless 200.
|
| 90 |
const TIME_PROPS = new Set([
|
| 91 |
'transition-delay', 'transition-duration', 'animation-delay', 'animation-duration',
|
| 92 |
+
// v0.8.0 - the animation shorthand takes bare numbers as ms too:
|
| 93 |
// `animation: spin 1s infinite` is fine, but `animation: ghost 400` used
|
| 94 |
// to become "ghost 400px" (invalid). 400 → 400ms, matching animate:.
|
| 95 |
'animation',
|
| 96 |
]);
|
| 97 |
|
| 98 |
// A practical whitelist of standard CSS properties (loud errors beat silent ones)
|
| 99 |
+
export const KNOWN_PROPS = new Set(('all,appearance,accent-color,align-content,align-items,align-self,align-tracks,aspect-ratio,' +
|
| 100 |
'backdrop-filter,backface-visibility,background,background-attachment,background-blend-mode,background-clip,background-color,background-image,background-origin,background-position,background-repeat,background-size,' +
|
| 101 |
'block-size,border,border-block,border-block-color,border-block-end,border-block-end-color,border-block-end-style,border-block-end-width,border-block-start,border-block-start-color,border-block-start-style,border-block-start-width,border-block-style,border-block-width,' +
|
| 102 |
'border-bottom,border-bottom-color,border-bottom-left-radius,border-bottom-right-radius,border-bottom-style,border-bottom-width,' +
|
|
|
|
| 135 |
'font.mono': 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
| 136 |
};
|
| 137 |
|
| 138 |
+
export const EASINGS = {
|
| 139 |
snap: 'cubic-bezier(0.2, 0.9, 0.3, 1)',
|
| 140 |
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
| 141 |
bounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
|
|
| 159 |
|
| 160 |
const IDENT_RE = /^-?(?:[A-Za-z_]|--[A-Za-z_])[A-Za-z0-9_.\\-]*/;
|
| 161 |
|
| 162 |
+
// functions whose numeric arguments are NOT lengths - numbers stay bare
|
| 163 |
const UNITLESS_FN = new Set(('oklch,oklab,lab,lch,hsl,hsla,rgb,rgba,hwb,color,color-mix,light-dark,' +
|
| 164 |
'brightness,saturate,contrast,grayscale,sepia,invert,opacity,' +
|
| 165 |
'scale,scalex,scaley,scalez,scale3d,steps,cubic-bezier,linear,radial,conic').split(','));
|
|
|
|
| 172 |
* - token references become var(--dssl-…), `token/NN` becomes color-mix
|
| 173 |
* - unknown identifiers are errors (with did-you-mean)
|
| 174 |
*/
|
| 175 |
+
/** split a selector list at top-level commas (not inside :is(...) and friends) */
|
| 176 |
+
function splitTopLevel(sel) {
|
| 177 |
+
const out = []; let depth = 0, cur = '';
|
| 178 |
+
for (const ch of String(sel)) {
|
| 179 |
+
if (ch === '(' || ch === '[') depth++;
|
| 180 |
+
if (ch === ')' || ch === ']') depth--;
|
| 181 |
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
| 182 |
+
cur += ch;
|
| 183 |
+
}
|
| 184 |
+
if (cur.trim()) out.push(cur.trim());
|
| 185 |
+
return out;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
function resolveValue(raw, ctx) {
|
| 189 |
let out = '';
|
| 190 |
let i = 0;
|
|
|
|
| 229 |
|
| 230 |
if (ident.startsWith('--')) { out += ident; continue; } // custom property
|
| 231 |
|
| 232 |
+
// function call - resolve the inside
|
| 233 |
if (raw[i] === '(') {
|
| 234 |
let depth = 1, j = i + 1;
|
| 235 |
while (j < n && depth > 0) {
|
|
|
|
| 243 |
}
|
| 244 |
const inner = raw.slice(i + 1, j - 1);
|
| 245 |
const fname = ident.toLowerCase();
|
| 246 |
+
// v0.8.0 - url() is atomic. The validator used to tokenize INSIDE it,
|
| 247 |
// so `url(a.png)` raised "unknown value a.png" (quoted urls worked
|
| 248 |
// only because strings were already consumed whole). CSS allows the
|
| 249 |
// unquoted form; DSSL passes it through untouched now.
|
|
|
|
| 252 |
i = j;
|
| 253 |
continue;
|
| 254 |
}
|
| 255 |
+
// v0.3.2 fix: repeat(COUNT, tracks) - the leading count is an integer,
|
| 256 |
// never a length. `repeat(4, 1fr)` used to become `repeat(4px, 1fr)`,
|
| 257 |
// which the browser rejected and the whole grid silently collapsed.
|
| 258 |
if (fname === 'repeat') {
|
|
|
|
| 262 |
i = j;
|
| 263 |
continue;
|
| 264 |
}
|
| 265 |
+
// auto-fill / auto-fit / nested weirdness - fall through to generic
|
| 266 |
}
|
| 267 |
// color-ish functions keep bare numbers bare; calc-family gets px
|
| 268 |
const innerUnitless = UNITLESS_FN.has(fname) ? true : LENGTH_FN.has(fname) ? false : ctx.unitless;
|
|
|
|
| 293 |
continue;
|
| 294 |
}
|
| 295 |
|
| 296 |
+
// v0.5.0 - font names are proper nouns: JetBrains Mono, IBM Plex Serif.
|
| 297 |
// In font-family values and font.* tokens, bare multi-word names pass
|
| 298 |
// through instead of demanding quotes. Everywhere else stays loud.
|
| 299 |
if (ctx.bareIdents && /^[A-Za-z][A-Za-z0-9_-]*$/.test(ident)) {
|
|
|
|
| 314 |
const hex = raw.slice(i, j);
|
| 315 |
out += hex;
|
| 316 |
i = j;
|
| 317 |
+
// v0.7.2 - alpha on a raw hex color: #ff0000/50 used to become
|
| 318 |
+
// #ff0000/50px (the bare number got the generic px treatment) - invalid
|
| 319 |
// CSS, silently dropped by the browser. Tokens always had /NN via
|
| 320 |
// color-mix; raw hex now gets the same treatment.
|
| 321 |
const am = raw.slice(i).match(/^\s*\/\s*([0-9.]+)\s*%?/);
|
|
|
|
| 331 |
return out;
|
| 332 |
}
|
| 333 |
|
| 334 |
+
/** paren-aware split on the `->` arrow - gradient stops use it as a separator */
|
| 335 |
function splitArrow(s) {
|
| 336 |
const out = [];
|
| 337 |
let cur = '', depth = 0, q = null;
|
|
|
|
| 348 |
return out;
|
| 349 |
}
|
| 350 |
|
| 351 |
+
/** split on a top-level separator (parens-aware) - commas inside fn() survive */
|
| 352 |
function topSplit(str, sep) {
|
| 353 |
const out = [];
|
| 354 |
let cur = '', depth = 0;
|
|
|
|
| 364 |
|
| 365 |
function findToken(tokens, ident) {
|
| 366 |
if (tokens.has(ident)) return ident;
|
| 367 |
+
// try progressive dot paths (a.b.c defined, user typed a.b.c - exact only, plus flat fallback)
|
| 368 |
if (ident.includes('.')) {
|
| 369 |
const parts = ident.split('.');
|
| 370 |
for (let k = parts.length - 1; k > 0; k--) {
|
| 371 |
const key = parts.slice(0, k).join('.');
|
| 372 |
+
if (tokens.has(key)) return key; // hmm - partial match would change meaning; require exact
|
| 373 |
}
|
| 374 |
}
|
| 375 |
return null;
|
|
|
|
| 382 |
const len = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: false });
|
| 383 |
const num = (raw, ctx) => resolveValue(raw, { ...ctx, unitless: true });
|
| 384 |
|
| 385 |
+
// v0.6.0 - the deform family speaks degrees, not lengths: bare numbers are
|
| 386 |
// angles and `20deg` is accepted for people with CSS muscle memory.
|
| 387 |
const degNum = (raw, what, ctx) => {
|
| 388 |
const t = String(raw).trim();
|
|
|
|
| 391 |
ctx.errors.push({
|
| 392 |
file: ctx.file, line: ctx.line,
|
| 393 |
message: `${what} takes a bare number of degrees`,
|
| 394 |
+
hint: `try ${what}: 20 or ${what}: -12 - bare numbers are degrees here`,
|
| 395 |
});
|
| 396 |
return null;
|
| 397 |
}
|
| 398 |
return parseFloat(m[1]);
|
| 399 |
};
|
| 400 |
|
| 401 |
+
/** v0.6.0 - shared grammar for gradient / gradient-text: kind · prefix · stops.
|
| 402 |
* Stops may be arrow-joined (`a -> b -> c`) or comma-joined (`linear 135, a, b`);
|
| 403 |
+
* the prefix is whatever the grammar calls direction/shape - everything else is a stop. */
|
| 404 |
// Stops are colors by construction, so a bare word that is not a color name is
|
| 405 |
// a typo the browser would only mutter about at apply time. Say it NOW instead.
|
| 406 |
const COLOR_WORDS = new Set(('black,silver,gray,white,maroon,red,purple,fuchsia,green,lime,olive,yellow,navy,blue,teal,aqua,' +
|
|
|
|
| 450 |
ctx.errors.push({
|
| 451 |
file: ctx.file, line: ctx.line,
|
| 452 |
message: `gradient needs at least two stops`,
|
| 453 |
+
hint: 'try gradient: linear 135, #ff0055 -> #00d4ff - commas work too: linear 135, #ff0055, #00d4ff',
|
| 454 |
});
|
| 455 |
return null;
|
| 456 |
}
|
|
|
|
| 491 |
ctx.errors.push({
|
| 492 |
file: ctx.file, line: ctx.line,
|
| 493 |
message: `"${head}" is not a conic gradient prefix`,
|
| 494 |
+
hint: 'use from <angle> and/or at <position> - try conic from 45 at center, …',
|
| 495 |
});
|
| 496 |
return null;
|
| 497 |
}
|
|
|
|
| 523 |
|
| 524 |
function parseFlexWords(value, ctx, dir) {
|
| 525 |
const out = [{ prop: 'display', value: 'flex' }, { prop: 'flex-direction', value: dir }];
|
| 526 |
+
// v0.8.0 - axis roles. Every place word used to write BOTH axes, so
|
| 527 |
// `row: gap 8 between center` (the guide's own example) compiled to
|
| 528 |
+
// justify-content: space-between followed by justify-content: center -
|
| 529 |
// and center won, bunching everything in the middle. Words now declare
|
| 530 |
// the axis they are actually about: distribution words (between/around/
|
| 531 |
// evenly) own the main axis, stretch/baseline own the cross axis, and
|
|
|
|
| 556 |
const axisWarn = (prop, prev) => {
|
| 557 |
ctx.warnings = ctx.warnings || [];
|
| 558 |
ctx.warnings.push(prev === a
|
| 559 |
+
? { file: ctx.file, line: ctx.line, message: `"${a}" is written twice in ${dir} - the second copy wins`, hint: `drop the duplicate; one distribution word + one alignment word is the whole grammar` }
|
| 560 |
+
: { file: ctx.file, line: ctx.line, message: `two words in ${dir} write ${prop}: "${a}" overrides "${prev}"`, hint: `later words win - drop one, or reorder so the distribution word comes first` });
|
| 561 |
};
|
| 562 |
let wrote = false;
|
| 563 |
if (role === 'main' || (role === 'both' && !mainSet)) {
|
|
|
|
| 575 |
ctx.warnings.push({
|
| 576 |
file: ctx.file, line: ctx.line,
|
| 577 |
message: mainSet === a || crossSet === a
|
| 578 |
+
? `"${a}" is written twice in ${dir} - both axes already came from it`
|
| 579 |
: `"${a}" has nowhere to go: justify-content came from "${mainSet}", align-items came from "${crossSet}"`,
|
| 580 |
+
hint: 'one distribution word + one alignment word is the whole grammar - the extra word is dropped',
|
| 581 |
});
|
| 582 |
}
|
| 583 |
continue;
|
|
|
|
| 587 |
return out;
|
| 588 |
}
|
| 589 |
|
| 590 |
+
// v0.6.0 - deform members all land in the transform channel, so asking for
|
| 591 |
// `transition: 200 snap bend` should just work and mean transform.
|
| 592 |
const DEFORM_TO_TRANSFORM = {
|
| 593 |
bend: 'transform', 'bend-y': 'transform', 'bend-up': 'transform',
|
|
|
|
| 605 |
column: (v, ctx) => parseFlexWords(v, ctx, 'column'),
|
| 606 |
grid(v, ctx) {
|
| 607 |
const atoms = v.split(/\s+/).filter(Boolean);
|
| 608 |
+
// v0.7.2 - `grid: 3 gap 12` (the documented form) used to die: the gap
|
| 609 |
// atoms were fed into grid-template-columns → "3px gap 12px", invalid.
|
| 610 |
// gap and rows are now first-class words of the shorthand.
|
| 611 |
let gap = null, rows = null;
|
|
|
|
| 617 |
}
|
| 618 |
let cols;
|
| 619 |
if (!rest.length) {
|
| 620 |
+
ctx.errors.push({ file: ctx.file, line: ctx.line, message: 'grid needs columns', hint: 'grid: 3 · grid: auto-fill 200 · grid: 200px 1fr - optionally with gap N and rows N' });
|
| 621 |
cols = 'none';
|
| 622 |
} else if (rest.length === 1 && /^[0-9]+$/.test(rest[0])) {
|
| 623 |
cols = `repeat(${rest[0]}, 1fr)`;
|
|
|
|
| 648 |
}
|
| 649 |
return [{ prop: 'place-items', value: `${p[0]} ${p[1]}` }, { prop: 'place-content', value: `${p[0]} ${p[1]}` }];
|
| 650 |
},
|
| 651 |
+
// v0.3.4 - the one-word centering primitive, because "how do I center this
|
| 652 |
// thing" is the first question everyone asks. Emits the alignment pair only;
|
| 653 |
// the ENGINE guarantees a formatting context for alignment declarations
|
| 654 |
// (inline elements → inline-grid, blocks → grid), so `center:` can never
|
|
|
|
| 735 |
{ prop: 'white-space', value: 'nowrap' },
|
| 736 |
];
|
| 737 |
},
|
| 738 |
+
// v0.4.0 - the accessibility classic: visually hidden, still announced by
|
| 739 |
// screen readers. One word instead of nine lines of clip folklore.
|
| 740 |
'visually-hidden'() {
|
| 741 |
return [
|
|
|
|
| 750 |
{ prop: 'border', value: '0' },
|
| 751 |
];
|
| 752 |
},
|
| 753 |
+
// v0.4.0 - transition grew per-property specs. Each comma part is
|
| 754 |
// duration [easing] [property] (property omitted → all)
|
| 755 |
// The one-value form keeps working exactly as before:
|
| 756 |
// transition: 200 snap
|
|
|
|
| 764 |
const atoms = part.split(/\s+/);
|
| 765 |
const dur = atoms[0];
|
| 766 |
if (!/^\d*\.?\d+(ms|s)?$/.test(dur)) {
|
| 767 |
+
// v0.8.0 - the CSS habit is property-first (`transition: color .2s
|
| 768 |
// ease`). That used to be a generic "not a time" error and the part
|
| 769 |
// silently dropped. The error now REWRITES the value into the DSSL
|
| 770 |
// order (time · easing · property) so the fix is a copy-paste.
|
|
|
|
| 776 |
ctx.errors.push({
|
| 777 |
file: ctx.file, line: ctx.line,
|
| 778 |
message: `transition: the CSS form (property first) is not DSSL`,
|
| 779 |
+
hint: `write transition: ${msOut}${easeAtom ? ' ' + easeAtom : ''}${propAtom ? ' ' + propAtom : ''} - time first, easing, property last`,
|
| 780 |
});
|
| 781 |
continue;
|
| 782 |
}
|
| 783 |
+
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `transition duration "${dur}" is not a time`, hint: 'order is duration · easing · property - try 200 or 0.2s' });
|
| 784 |
continue;
|
| 785 |
}
|
| 786 |
const ms = /^\d+$/.test(dur) ? dur + 'ms' : dur;
|
|
|
|
| 820 |
}
|
| 821 |
if (EASINGS[a]) { spec.easing = EASINGS[a]; continue; }
|
| 822 |
if (['both', 'forwards', 'backwards', 'none', 'auto'].includes(a)) { spec.fill = a; continue; }
|
| 823 |
+
// v0.5.0 - looping: `infinite` runs forever, `alternate` ping-pongs.
|
| 824 |
+
// animate: pulse 1600 infinite alternate - was impossible before.
|
| 825 |
if (a === 'infinite') { spec.iterations = Infinity; continue; }
|
| 826 |
if (['alternate', 'alternate-reverse', 'reverse', 'normal'].includes(a)) { spec.direction = a; continue; }
|
| 827 |
ctx.errors.push({ file: ctx.file, line: ctx.line, message: `unknown animate part "${a}"`, hint: didYouMean(a, Object.keys(EASINGS).concat(['both', 'forwards', 'backwards', 'infinite', 'alternate'])) });
|
| 828 |
}
|
| 829 |
return [{ prop: '@animate', value: spec }];
|
| 830 |
},
|
| 831 |
+
// v0.4.0 - choreography: a parent with `stagger: 60` offsets each element
|
| 832 |
// child's animation delay by its DOM position. Pairs with `seen` + `animate`
|
| 833 |
// on the children: grids and lists cascade in as they scroll into view.
|
| 834 |
stagger(v, ctx) {
|
|
|
|
| 841 |
const ms = t.endsWith('s') && !t.endsWith('ms') ? parseFloat(t) * 1000 : parseFloat(t);
|
| 842 |
return [{ prop: '@stagger', value: ms }];
|
| 843 |
},
|
| 844 |
+
// v0.5.0 - overlap policy. CSS can stack transparent things all day and say
|
| 845 |
// nothing; DSSL refuses. `deny` makes the element always yield out of any
|
| 846 |
// overlap; `auto` yields only while the element sits below 100% opacity.
|
| 847 |
// Both inherit down the tree, so `body { overlap: auto }` is the site-wide
|
|
|
|
| 861 |
},
|
| 862 |
|
| 863 |
/* ---------------------------------------------------------------- *
|
| 864 |
+
* v0.6.0 - the deform family: bend, bend-y, bend-up, fold, stretch,
|
| 865 |
* bend-around. All are engine-native (@-props) that compose into the
|
| 866 |
* transform channel, so `transition: 200 snap transform` tweens them
|
| 867 |
* and `:active { bend: 14 }` makes a button visibly flex under the
|
|
|
|
| 878 |
if (Math.abs(n) > 170) {
|
| 879 |
ctx.errors.push({
|
| 880 |
file: ctx.file, line: ctx.line,
|
| 881 |
+
message: `bend-around caps at ±170° - past that the arc folds back on itself`,
|
| 882 |
hint: 'try 30..120 for a visible arc',
|
| 883 |
});
|
| 884 |
return [];
|
|
|
|
| 901 |
ctx.errors.push({
|
| 902 |
file: ctx.file, line: ctx.line,
|
| 903 |
message: `stretch: ${m[1]} would flatten the element to nothing`,
|
| 904 |
+
hint: 'use a factor above 0 - 1.2 is a gentle pull, 2 is cartoonish',
|
| 905 |
});
|
| 906 |
return [];
|
| 907 |
}
|
|
|
|
| 910 |
return [{ prop: '@stretch', value: { factor: f, axis } }];
|
| 911 |
},
|
| 912 |
|
| 913 |
+
/* v0.6.0 - gradient tools. `gradient` replaces the CSS function soup with
|
| 914 |
* kind · prefix · stop -> stop; stops may be colors, tokens, alpha mixes
|
| 915 |
* (ink/40) and carry a trailing % position. gradient-text clips the same
|
| 916 |
* gradient into the glyphs. gradient-flow loops background-position so a
|
| 917 |
+
* gradient drifts forever - one line instead of keyframe folklore. */
|
| 918 |
gradient(v, ctx) {
|
| 919 |
const img = buildGradient(v, ctx);
|
| 920 |
return img ? [{ prop: 'background-image', value: img }] : [];
|
|
|
|
| 959 |
},
|
| 960 |
};
|
| 961 |
|
| 962 |
+
/* v0.8.0 - fluid sizing sugar. `fluid: 16, 24` keeps working (font-size),
|
| 963 |
* and now any length-ish property takes it inline: `font-size: fluid 16 24`.
|
| 964 |
* Both compile to a clamp() that scales linearly between the viewport bounds
|
| 965 |
+
* (360px and 1200px by default) - the formula everyone writes by hand and
|
| 966 |
* gets wrong. */
|
| 967 |
const FLUID_PROPS = new Set(('font-size,width,height,inline-size,block-size,min-width,max-width,min-height,max-height,' +
|
| 968 |
'gap,row-gap,column-gap,padding,margin,inset,top,right,bottom,left,' +
|
|
|
|
| 1008 |
}
|
| 1009 |
|
| 1010 |
/* ------------------------------------------------------------------ *
|
| 1011 |
+
* 0.9.0: texture, aura and slider
|
| 1012 |
+
*
|
| 1013 |
+
* All three validate their own vocabulary in their own module. Here they only
|
| 1014 |
+
* get hooks: how to report an error on this line, how to turn a colour word
|
| 1015 |
+
* into CSS (tokens, alpha and all), and how to suggest a correction.
|
| 1016 |
+
* ------------------------------------------------------------------ */
|
| 1017 |
+
|
| 1018 |
+
function styleHooks(ctx) {
|
| 1019 |
+
return {
|
| 1020 |
+
error: (message, hint) => ctx.errors.push({ file: ctx.file, line: ctx.line, message, hint: hint || undefined }),
|
| 1021 |
+
color: (word) => {
|
| 1022 |
+
const before = ctx.errors.length;
|
| 1023 |
+
const css = resolveValue(word, { ...ctx, unitless: false, bareIdents: false });
|
| 1024 |
+
return ctx.errors.length > before ? null : css;
|
| 1025 |
+
},
|
| 1026 |
+
suggest: (word, list) => { const h = didYouMean(word, list); return h ? `did you mean "${h}"?` : null; },
|
| 1027 |
+
};
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
SEMANTIC.texture = function texture(v, ctx) {
|
| 1031 |
+
const layers = parseTexture(v, styleHooks(ctx));
|
| 1032 |
+
if (layers === null) return [];
|
| 1033 |
+
return [{ prop: '@texture', value: layers.length ? layers : null }];
|
| 1034 |
+
};
|
| 1035 |
+
|
| 1036 |
+
function auraDecls(prop, v, ctx) {
|
| 1037 |
+
const r = parseAura(prop, v, styleHooks(ctx));
|
| 1038 |
+
if (!r) return [];
|
| 1039 |
+
const out = [];
|
| 1040 |
+
// an empty list or none clears the slot; undefined means this property does not touch it
|
| 1041 |
+
if (r.glow !== undefined) out.push({ prop: '@aura-glow', value: r.glow && r.glow.length ? r.glow : null });
|
| 1042 |
+
if (r.particles !== undefined) out.push({ prop: '@aura-particles', value: r.particles && r.particles.length ? r.particles : null });
|
| 1043 |
+
return out;
|
| 1044 |
+
}
|
| 1045 |
+
for (const name of ['aura', 'aura-glow', 'aura-particle', 'aura-particles', 'glow-particle', 'glow-particles', 'aura-glow-particle', 'aura-glow-particles']) {
|
| 1046 |
+
SEMANTIC[name] = (v, ctx) => auraDecls(name, v, ctx);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
SEMANTIC.slider = function slider(v, ctx) {
|
| 1050 |
+
const spec = parseSlider(v, styleHooks(ctx));
|
| 1051 |
+
if (!spec) return [];
|
| 1052 |
+
return [{ prop: '@slider', value: spec }];
|
| 1053 |
+
};
|
| 1054 |
+
|
| 1055 |
+
/* ------------------------------------------------------------------ *
|
| 1056 |
+
* resolveDecl - one declaration → zero or more engine decls
|
| 1057 |
* ------------------------------------------------------------------ */
|
| 1058 |
|
| 1059 |
function resolveDecl(prop, value, ctx) {
|
| 1060 |
const errors = ctx.errors;
|
| 1061 |
const ctx2 = { ...ctx, prop, errors, warnings: ctx.warnings };
|
| 1062 |
|
| 1063 |
+
// v0.8.0 - !important does not exist in DSSL. It used to fall through as a
|
| 1064 |
// value: the compiler said "unknown value important" AND still emitted
|
| 1065 |
// `red !important`, which the browser then silently dropped as an invalid
|
| 1066 |
// inline value. Now: one loud error, nothing emitted, and the pin { }
|
|
|
|
| 1068 |
if (hasImportant(value)) {
|
| 1069 |
errors.push({
|
| 1070 |
file: ctx.file, line: ctx.line,
|
| 1071 |
+
message: `!important does not exist in DSSL - "${prop}: ${String(value).replace(/\s*!\s*important\s*/i, '')}" was NOT applied`,
|
| 1072 |
+
hint: 'the cascade is (layer, order) and later wins - put the rule in pin { } when it genuinely must win',
|
| 1073 |
});
|
| 1074 |
return [];
|
| 1075 |
}
|
| 1076 |
|
| 1077 |
if (prop.startsWith('--')) {
|
| 1078 |
+
// v0.4.0 - scoped tokens. Dotted names are token re-declarations:
|
| 1079 |
// .danger { --color.accent: color.err }
|
| 1080 |
// sets --dssl-color-accent ON the element, and custom properties inherit,
|
| 1081 |
// so every var() reference in the subtree re-resolves. Plain names
|
|
|
|
| 1089 |
const sem = SEMANTIC[prop];
|
| 1090 |
if (sem) return sem(value, ctx2);
|
| 1091 |
|
| 1092 |
+
// v0.8.0 - the animation shorthand passes through (it is legal CSS), but
|
| 1093 |
// bare motion names must not pay the generic unknown-value tax: "spin"
|
| 1094 |
// used to error with the surreal suggestion "did you mean span?". Motion
|
| 1095 |
// names pass silently here and a dedicated warning in the final loop says
|
|
|
|
| 1098 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: true }) }];
|
| 1099 |
}
|
| 1100 |
|
| 1101 |
+
// v0.8.0 - inline fluid sugar on any length-ish property:
|
| 1102 |
// font-size: fluid 16 24 → clamp(16px, calc(16px + 0.952vw), 24px)
|
| 1103 |
const fluidM = /^fluid\s+([\s\S]+)$/.exec(String(value).trim());
|
| 1104 |
if (fluidM && FLUID_PROPS.has(prop)) {
|
|
|
|
| 1116 |
return [{ prop, value: resolveValue(value, { ...ctx2, unitless: UNITLESS_PROPS.has(prop), bareIdents: prop === 'font-family' }) }];
|
| 1117 |
}
|
| 1118 |
|
| 1119 |
+
/** string-aware !important scan - quoted content: "!important" must not trip it */
|
| 1120 |
function hasImportant(v) {
|
| 1121 |
const s = String(v);
|
| 1122 |
let q = null;
|
|
|
|
| 1179 |
const errors = [];
|
| 1180 |
const rules = [];
|
| 1181 |
const shadowRules = [];
|
| 1182 |
+
const raws = []; // v0.8.0 - raw { } / @css { } verbatim passthrough blocks
|
| 1183 |
const motions = {};
|
| 1184 |
const behaviors = {};
|
| 1185 |
+
// v0.7.0 - compile-time warnings: things that compile and run but are
|
| 1186 |
+
// probably not what the author meant. Separate from errors - ok stays ok -
|
| 1187 |
// but the engine prints them loudly so nobody debugs cascade order by hand
|
| 1188 |
// ever again.
|
| 1189 |
const warnings = [];
|
|
|
|
| 1219 |
} else if (node.kind === 'motion') {
|
| 1220 |
motions[node.name] = node;
|
| 1221 |
} else {
|
| 1222 |
+
// v0.8.0 - defensive `|| []`: a malformed tree can throw from here
|
| 1223 |
// and take the whole stylesheet down (the @font-face crash report).
|
| 1224 |
// The parser now skips unknown at-rules whole, but the guard stays.
|
| 1225 |
for (const child of (node.children || [])) if (child.kind !== 'decl') prepass([child]);
|
|
|
|
| 1258 |
continue;
|
| 1259 |
}
|
| 1260 |
const props = {};
|
| 1261 |
+
const deform = {}; // v0.6.0 - bend/stretch/fold in keyframes compose into transform
|
| 1262 |
const DEFORM_KEY = { '@bend': 'bend', '@bend-y': 'bendY', '@fold': 'fold', '@bend-up': 'bendUp' };
|
| 1263 |
for (const d of child.children) {
|
| 1264 |
if (d.kind !== 'decl') continue;
|
|
|
|
| 1280 |
deform.fold !== undefined || deform.bendUp !== undefined) {
|
| 1281 |
// no box exists at resolve time, so edge-pinned members ride a
|
| 1282 |
// transformOrigin keyframe property instead of baked translates.
|
| 1283 |
+
// Presence (not truthiness) - `bend: 0` in a frame is a real reset.
|
| 1284 |
const c = composeDeform(deform, props.transform || '', null);
|
| 1285 |
props.transform = c.transform;
|
| 1286 |
if (c.origin) props.transformOrigin = c.origin;
|
|
|
|
| 1302 |
for (const key of keys) {
|
| 1303 |
const raw = tokenMap.get(key);
|
| 1304 |
const prop = '--dssl-' + key.replace(/\./g, '-');
|
| 1305 |
+
// font.* tokens hold font stacks - proper nouns, not keywords (v0.5.0)
|
| 1306 |
decls.push({ prop, value: resolveValue(raw, { ...ctxBase, prop: key, bareIdents: /^font/.test(key) }) });
|
| 1307 |
}
|
| 1308 |
if (!decls.length) return null;
|
|
|
|
| 1321 |
/* ---- main recursive walk ---- */
|
| 1322 |
|
| 1323 |
const walk = (children, ctx, ctxRule) => {
|
| 1324 |
+
// v0.7.2 - parent-first flush. Two real bugs lived here. (1) A nested
|
| 1325 |
// block (hover { }, @media, a nested selector) was emitted BEFORE the
|
| 1326 |
// parent's own declarations got their cascade order, so `background: red`
|
| 1327 |
// on the parent always beat `hover { background: red }` nested inside it
|
| 1328 |
+
// - the exact pattern the docs taught, and the external review's headline
|
| 1329 |
// finding. (2) media/container/signal/layer blocks flushed with the
|
| 1330 |
// PARENT context, dropping the condition or layer entirely: a nested
|
| 1331 |
// `@media dark` was silently unconditional AND sorted before base.
|
|
|
|
| 1355 |
}
|
| 1356 |
const resolved = resolveDecl(node.prop, node.value, { file, line: node.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: node.prop, unitless: false });
|
| 1357 |
for (const r of resolved) {
|
| 1358 |
+
// v0.7.0 - remember WHICH source property wrote a display value,
|
| 1359 |
// so the ordering linter can tell an explicit "display:" from a
|
| 1360 |
// shorthand that expanded into one (column: → display: flex).
|
| 1361 |
if (r.prop === 'display' && node.prop !== 'display') r.via = node.prop;
|
|
|
|
| 1366 |
case 'selector': {
|
| 1367 |
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(node.sel) && ctx.sel) {
|
| 1368 |
const near = didYouMean(node.sel.toLowerCase(), [...STATE_WORDS]);
|
| 1369 |
+
if (near) warnings.push({ file, line: node.line, message: `"${node.sel}" compiled as a descendant selector ("${combine(ctx.sel, node.sel)}")`, hint: `if you meant the state, it is spelled "${near}" - this block currently styles descendants called <${node.sel}>` });
|
| 1370 |
}
|
| 1371 |
flushPending(node.line);
|
| 1372 |
const sel = ctx.sel === '' ? node.sel : combine(ctx.sel, node.sel);
|
|
|
|
| 1383 |
}
|
| 1384 |
flushPending(node.line);
|
| 1385 |
const childCtx = { ...ctx, sel: ctx.sel + ':' + node.name };
|
| 1386 |
+
// v0.7.2 - the arrow is motion/gradient-stop syntax; inside a seen
|
| 1387 |
// block it compiled into an inline style the browser rejects, so the
|
| 1388 |
// element just sat at its base style forever. Say so, loudly, and
|
| 1389 |
// point at the two real patterns (final value, or a motion).
|
| 1390 |
if (node.name === 'seen') {
|
| 1391 |
for (const d of node.children) {
|
| 1392 |
if (d.kind === 'decl' && typeof d.value === 'string' && d.value.includes('->')) {
|
| 1393 |
+
errors.push({ file, line: d.line, message: `"${d.prop}: ${d.value}" - the -> arrow is motion syntax and does nothing inside seen { }`, hint: 'write the final style (seen { opacity: 1 }) or attach a motion (seen { animate: rise-in 400 })' });
|
| 1394 |
}
|
| 1395 |
}
|
| 1396 |
}
|
|
|
|
| 1447 |
if (d.kind !== 'decl') { errors.push({ file, line: d.line, message: 'only declarations may appear inside a pseudo-element block (v0.2)' }); continue; }
|
| 1448 |
if (d.prop === 'content' && d.value === '') { srule.decls.push({ prop: 'content', value: '""', line: d.line }); continue; }
|
| 1449 |
const resolved = resolveDecl(d.prop, d.value, { file, line: d.line, errors, warnings, tokens: tokenMap, tokenNames: tokenNames(), prop: d.prop, unitless: false });
|
| 1450 |
+
// v0.5.0 - engine-native properties have no pseudo-element story:
|
| 1451 |
// there is no DOM handle to nudge, animate, stack or stagger. They
|
| 1452 |
// used to leak into the shadow sheet as junk CSS the browser
|
| 1453 |
+
// silently dropped - exactly the silence this language refuses.
|
| 1454 |
for (const r of resolved) {
|
| 1455 |
if (r.prop.startsWith('@')) {
|
| 1456 |
+
errors.push({ file, line: d.line, message: `"${d.prop}" is engine-native and has no meaning on ::${node.name} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo block' });
|
| 1457 |
continue;
|
| 1458 |
}
|
| 1459 |
srule.decls.push({ ...r, line: d.line });
|
|
|
|
| 1473 |
break;
|
| 1474 |
}
|
| 1475 |
case 'raw': {
|
| 1476 |
+
// v0.8.0 - verbatim CSS escape hatch (@font-face, @keyframes,
|
| 1477 |
// @supports, @import, @property…). The parser captured the body
|
| 1478 |
// untouched; it rides the same document stylesheet as the
|
| 1479 |
// pseudo-element rules and survives `dssl build` verbatim.
|
|
|
|
| 1509 |
function flush(sub, ctx, line) {
|
| 1510 |
if (!sub.decls.length) return;
|
| 1511 |
if (!ctx.sel) {
|
| 1512 |
+
errors.push({ file, line, message: 'declarations must live inside a selector', hint: 'top-level blocks are tokens, behavior, motion, @layer, @media… - or a selector like .card' });
|
| 1513 |
return;
|
| 1514 |
}
|
| 1515 |
+
// v0.5.0 - the pseudo-element trap fix. A full selector like `.card::before`
|
| 1516 |
// used to compile ok and then match NOTHING: inline styles can never reach
|
| 1517 |
// pseudo-elements. Now the selector is split into element + pseudo halves
|
| 1518 |
+
// and the rule rides the shadow-CSS pipeline - exactly what the nested
|
| 1519 |
// form (`.card { ::before { } }`) always did. Both forms are now one thing.
|
| 1520 |
const ps = analyzePseudo(ctx.sel);
|
| 1521 |
if (ps.mode === 'all') {
|
| 1522 |
// parity with the nested path: bare `content:` becomes content: ""
|
| 1523 |
+
// v0.5.0 - and engine-native properties fail loud here too (see the
|
| 1524 |
// nested pseudo path): no DOM handle means no nudge, animate or stack.
|
| 1525 |
const decls = [];
|
| 1526 |
for (const d of sub.decls) {
|
| 1527 |
if (d.prop && d.prop.startsWith('@')) {
|
| 1528 |
+
errors.push({ file, line, message: `"${d.prop}" is engine-native and has no meaning on ::${ps.parts[0].pseudo.split(/[\s:(]/)[0]} - a pseudo-element has no DOM handle`, hint: 'put it on the element itself, not inside the pseudo rule' });
|
| 1529 |
continue;
|
| 1530 |
}
|
| 1531 |
decls.push(d.prop === 'content' && d.value === '' ? { ...d, value: '""' } : d);
|
|
|
|
| 1544 |
errors.push({
|
| 1545 |
file, line,
|
| 1546 |
message: '::slotted() only exists inside a shadow tree, and DSSL styles the light DOM',
|
| 1547 |
+
hint: 'style the slotted element itself with a normal selector - it IS reachable from the page',
|
| 1548 |
});
|
| 1549 |
continue;
|
| 1550 |
}
|
|
|
|
| 1556 |
});
|
| 1557 |
continue;
|
| 1558 |
}
|
| 1559 |
+
// v0.5.0 - parity with the nested path: if every declaration was
|
| 1560 |
// rejected (engine-native props, bad values), emit NOTHING rather
|
| 1561 |
// than an empty ghost rule that matches and styles zero things.
|
| 1562 |
if (!decls.length) continue;
|
|
|
|
| 1569 |
return;
|
| 1570 |
}
|
| 1571 |
if (ps.mode === 'mixed') {
|
| 1572 |
+
errors.push({ file, line, message: `selector list mixes pseudo-element and plain selectors: "${ctx.sel}"`, hint: 'split it into two rules - one for the pseudo-element, one for the rest' });
|
| 1573 |
return;
|
| 1574 |
}
|
| 1575 |
+
// 0.9.0: slider. One description becomes the base decls on the input (put FIRST so the
|
| 1576 |
+
// author's own declarations in the same rule win) and one shadow rule per vendor pseudo.
|
| 1577 |
+
const sliderDecl = sub.decls.find((d) => d.prop === '@slider');
|
| 1578 |
+
if (sliderDecl && sliderDecl.value) {
|
| 1579 |
+
const css = sliderCss(sliderDecl.value);
|
| 1580 |
+
sub.decls.unshift(...css.base.map((d) => ({ ...d, line })));
|
| 1581 |
+
for (const part of splitTopLevel(ctx.sel)) {
|
| 1582 |
+
for (const sh of css.shadow) {
|
| 1583 |
+
shadowRules.push({
|
| 1584 |
+
id: ruleId++, sel: part, pseudo: sh.pseudo,
|
| 1585 |
+
media: ctx.media, signals: ctx.signals, containers: ctx.containers,
|
| 1586 |
+
layer: ctx.layer, order: order++, decls: sh.decls.map((d) => ({ ...d, line })), file, line,
|
| 1587 |
+
});
|
| 1588 |
+
}
|
| 1589 |
+
}
|
| 1590 |
+
}
|
| 1591 |
warnDisplayClash(sub.decls);
|
| 1592 |
rules.push({
|
| 1593 |
id: ruleId++, sel: ctx.sel, media: ctx.media, signals: ctx.signals,
|
|
|
|
| 1597 |
});
|
| 1598 |
}
|
| 1599 |
|
| 1600 |
+
/* v0.7.0 - the ordering linter. Within one rule, later wins; that is the
|
| 1601 |
* whole cascade model. But "column: gap 2" quietly expanding into
|
| 1602 |
* display: flex after an explicit "display: none" reads like a menu
|
| 1603 |
+
* toggle and behaves like a bug report - a real reviewer lost an evening
|
| 1604 |
* to exactly this ("their own source comments warn about this, but it
|
| 1605 |
* should be a compiler warning, not a comment"). Agreed. When a
|
| 1606 |
* shorthand's expansion clobbers an explicit display written earlier in
|
| 1607 |
* the SAME rule, say so at compile time. Cross-rule overrides are the
|
| 1608 |
+
* documented cascade at work, not an accident - no warning there. */
|
| 1609 |
function warnDisplayClash(decls) {
|
| 1610 |
let explicit = null;
|
| 1611 |
for (const d of decls) {
|
|
|
|
| 1614 |
if (explicit && explicit.value !== d.value && warnings.length < 400) {
|
| 1615 |
warnings.push({
|
| 1616 |
file, line: d.line,
|
| 1617 |
+
message: `"${d.via}:" expands to display: ${d.value}, which overrides the earlier "display: ${explicit.value}" (line ${explicit.line}) in the same rule - later wins, so the earlier one is dead`,
|
| 1618 |
hint: `if the override is intended, delete the dead line; otherwise put "${d.via}:" first, or move the display you want to win AFTER it`,
|
| 1619 |
});
|
| 1620 |
}
|
|
|
|
| 1637 |
errors.push({
|
| 1638 |
file, line: d.line,
|
| 1639 |
message: `unknown motion "${d.value.motion}"`,
|
| 1640 |
+
hint: didYouMean(d.value.motion, Object.keys(motions)) || `define it first: motion ${d.value.motion} { from { } to { } } - then "animate: ${d.value.motion} 400" runs it`,
|
| 1641 |
});
|
| 1642 |
d.value.broken = true;
|
| 1643 |
} else {
|
|
|
|
| 1645 |
d.value.essential = m.essential;
|
| 1646 |
}
|
| 1647 |
}
|
| 1648 |
+
// v0.8.0 - the animation shorthand is CSS passthrough, so a motion
|
| 1649 |
// name that matches nothing used to be a SILENT no-op (no @keyframes
|
| 1650 |
// can exist in DSSL). Warn with the whole story; if the keyframes come
|
| 1651 |
// from raw { } or another stylesheet, the warning says it can be ignored.
|
|
|
|
| 1655 |
if (words.length && !named) {
|
| 1656 |
warnings.push({
|
| 1657 |
file, line: d.line,
|
| 1658 |
+
message: `animation "${d.value}" references no motion defined in this sheet - it will not run`,
|
| 1659 |
+
hint: `motions are defined with motion name { from { } to { } } and run with animate: name 400 - @keyframes does not exist in DSSL; raw { } can carry verbatim keyframes`,
|
| 1660 |
});
|
| 1661 |
}
|
| 1662 |
}
|
| 1663 |
}
|
| 1664 |
}
|
| 1665 |
|
| 1666 |
+
// v0.8.0 - pre-parse EVERY motion (not just referenced ones) so the static
|
| 1667 |
// emitter can turn the whole motion vocabulary into real @keyframes.
|
| 1668 |
const motionFrames = {};
|
| 1669 |
for (const name of Object.keys(motions)) {
|
|
|
|
| 1671 |
if (m && m.frames.length) motionFrames[name] = m;
|
| 1672 |
}
|
| 1673 |
|
| 1674 |
+
// 0.9.0: the lint pass. Warnings only, so ok stays ok; `dssl check --strict` makes them fatal.
|
| 1675 |
+
try { warnings.push(...lintRules({ rules, tokenMap, file })); } catch { /* a lint bug must never break a compile */ }
|
| 1676 |
+
|
| 1677 |
const mediaQueries = new Set();
|
| 1678 |
for (const r of [...rules, ...shadowRules]) r.media.forEach(q => mediaQueries.add(q));
|
| 1679 |
|
|
|
|
| 1686 |
return parent + ' ' + child;
|
| 1687 |
}
|
| 1688 |
|
| 1689 |
+
/* v0.5.0 - where does a selector carry a pseudo-element?
|
| 1690 |
+
* 'none' - plain selector, inline-style path
|
| 1691 |
+
* 'all' - every comma-part has one: { parts: [{ el, pseudo }] } → shadow CSS
|
| 1692 |
+
* 'mixed' - some parts have one, some don't → loud error (split the list)
|
| 1693 |
* The `::` scan is string- and paren-aware so [data-x="::a"] can't false-hit. */
|
| 1694 |
function analyzePseudo(sel) {
|
| 1695 |
// top-level comma split
|
dssl-vscode/lib/engine/slider.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: slider.js
|
| 3 |
+
*
|
| 4 |
+
* input[type=range] {
|
| 5 |
+
* slider: track 6 color.line radius full, fill color.accent,
|
| 6 |
+
* thumb 24 color.paper shape circle, ring 3 color.accent,
|
| 7 |
+
* glow color.accent 14, ticks 10 color.muted
|
| 8 |
+
* }
|
| 9 |
+
*
|
| 10 |
+
* A range input is styled through vendor pseudo-elements, and every engine has
|
| 11 |
+
* its own: ::-webkit-slider-runnable-track and ::-webkit-slider-thumb for
|
| 12 |
+
* Chrome, Edge, Safari and Opera, ::-moz-range-track, ::-moz-range-thumb and
|
| 13 |
+
* ::-moz-range-progress for Firefox. Writing both sets by hand is the pain.
|
| 14 |
+
* `slider:` writes both from one description.
|
| 15 |
+
*
|
| 16 |
+
* It is plain CSS, with one small helper: the filled part of the track (and
|
| 17 |
+
* the coloured bars of a graph) follow the value, which CSS cannot know, so
|
| 18 |
+
* the engine keeps --dssl-p (the value as 0..1) up to date on the input.
|
| 19 |
+
*
|
| 20 |
+
* Clauses, comma separated:
|
| 21 |
+
* track <height> [color] [radius <n>|full]
|
| 22 |
+
* fill <color> | fill none
|
| 23 |
+
* thumb <size> [color]
|
| 24 |
+
* shape circle | square | diamond | pill | bar
|
| 25 |
+
* ring <width> <color> a border around the thumb
|
| 26 |
+
* glow <color> <radius> a soft light around the thumb
|
| 27 |
+
* ticks <count> [color] [length] little marks along the track
|
| 28 |
+
* graph <v1> <v2> ... bars (0 to 100) behind the track, a histogram slider
|
| 29 |
+
* graph-height <px> graph-gap <px>
|
| 30 |
+
*
|
| 31 |
+
* Pure: no DOM.
|
| 32 |
+
* ========================================================================= */
|
| 33 |
+
|
| 34 |
+
import { splitWords } from './texture.js';
|
| 35 |
+
|
| 36 |
+
export const SLIDER_CLAUSES = {
|
| 37 |
+
track: 'track [height] [color] [radius <n>|full]',
|
| 38 |
+
fill: 'fill <color> | fill none',
|
| 39 |
+
thumb: 'thumb <size> [color]',
|
| 40 |
+
shape: 'shape circle | square | diamond | pill | bar',
|
| 41 |
+
ring: 'ring <width> <color>',
|
| 42 |
+
glow: 'glow <color> <radius>',
|
| 43 |
+
ticks: 'ticks <count> [color] [length]',
|
| 44 |
+
graph: 'graph <v1> <v2> ... (each 0 to 100)',
|
| 45 |
+
'graph-height': 'graph-height <px>',
|
| 46 |
+
'graph-gap': 'graph-gap <px>',
|
| 47 |
+
};
|
| 48 |
+
export const THUMB_SHAPES = ['circle', 'square', 'diamond', 'pill', 'bar'];
|
| 49 |
+
|
| 50 |
+
function splitCommas(s) {
|
| 51 |
+
const out = []; let depth = 0, cur = '';
|
| 52 |
+
for (const ch of s) {
|
| 53 |
+
if (ch === '(') depth++;
|
| 54 |
+
if (ch === ')') depth--;
|
| 55 |
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
| 56 |
+
cur += ch;
|
| 57 |
+
}
|
| 58 |
+
if (cur.trim()) out.push(cur.trim());
|
| 59 |
+
return out;
|
| 60 |
+
}
|
| 61 |
+
const isNum = (w) => w !== undefined && w !== '' && !Number.isNaN(Number(w));
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* hooks: { error(msg, hint), color(word) -> css | null, suggest(word, list) -> string | null }
|
| 65 |
+
* Returns a spec object, or null when something was wrong.
|
| 66 |
+
*/
|
| 67 |
+
export function parseSlider(value, hooks) {
|
| 68 |
+
const text = String(value || '').trim();
|
| 69 |
+
if (!text) { hooks.error('slider needs a description', 'e.g. slider: track 6 color.line, fill color.accent, thumb 22 color.accent'); return null; }
|
| 70 |
+
const spec = { track: { h: 6, color: 'var(--dssl-color-line)', radius: 'full' }, fill: 'var(--dssl-color-accent)', thumb: { size: 22, color: 'var(--dssl-color-accent)' }, shape: 'circle', ring: null, glow: null, ticks: null, graph: null, graphHeight: 40, graphGap: 2 };
|
| 71 |
+
const bad = (m, h) => { hooks.error(m, h); return null; };
|
| 72 |
+
const col = (w) => hooks.color(w);
|
| 73 |
+
|
| 74 |
+
for (const clause of splitCommas(text)) {
|
| 75 |
+
const w = splitWords(clause);
|
| 76 |
+
const head = w[0];
|
| 77 |
+
if (!(head in SLIDER_CLAUSES)) {
|
| 78 |
+
return bad(`unknown slider setting "${head}"`, hooks.suggest(head, Object.keys(SLIDER_CLAUSES)) || `settings: ${Object.keys(SLIDER_CLAUSES).join(', ')}`);
|
| 79 |
+
}
|
| 80 |
+
const rest = w.slice(1);
|
| 81 |
+
// each clause reports how many words it understood; anything left over is a mistake, not noise
|
| 82 |
+
let used = rest.length;
|
| 83 |
+
if (head === 'track') {
|
| 84 |
+
// any order: a number is the height, "radius <n>|full" the corners, anything else a colour
|
| 85 |
+
let i = 0;
|
| 86 |
+
while (i < rest.length) {
|
| 87 |
+
const x = rest[i];
|
| 88 |
+
if (x === 'radius') {
|
| 89 |
+
const r = rest[i + 1];
|
| 90 |
+
if (r === 'full') spec.track.radius = 'full';
|
| 91 |
+
else if (isNum(r)) spec.track.radius = Number(r);
|
| 92 |
+
else return bad('track radius needs a number or "full"', SLIDER_CLAUSES.track);
|
| 93 |
+
i += 2;
|
| 94 |
+
} else if (isNum(x)) { spec.track.h = Number(x); i++; }
|
| 95 |
+
else { const c = col(x); if (c === null) return null; spec.track.color = c; i++; }
|
| 96 |
+
}
|
| 97 |
+
} else if (head === 'fill') {
|
| 98 |
+
used = 1;
|
| 99 |
+
if (rest[0] === 'none') spec.fill = null;
|
| 100 |
+
else { const c = col(rest[0]); if (c === null) return null; spec.fill = c; }
|
| 101 |
+
} else if (head === 'thumb') {
|
| 102 |
+
if (!isNum(rest[0])) return bad('thumb needs a size in px', SLIDER_CLAUSES.thumb);
|
| 103 |
+
spec.thumb.size = Number(rest[0]);
|
| 104 |
+
used = 1;
|
| 105 |
+
if (rest[1]) { used = 2; const c = col(rest[1]); if (c === null) return null; spec.thumb.color = c; }
|
| 106 |
+
} else if (head === 'shape') {
|
| 107 |
+
if (!THUMB_SHAPES.includes(rest[0])) return bad(`unknown thumb shape "${rest[0] ?? ''}"`, hooks.suggest(rest[0] || '', THUMB_SHAPES) || `use one of: ${THUMB_SHAPES.join(', ')}`);
|
| 108 |
+
spec.shape = rest[0];
|
| 109 |
+
used = 1;
|
| 110 |
+
} else if (head === 'ring') {
|
| 111 |
+
if (!isNum(rest[0]) || !rest[1]) return bad('ring needs a width and a colour', SLIDER_CLAUSES.ring);
|
| 112 |
+
const c = col(rest[1]); if (c === null) return null;
|
| 113 |
+
spec.ring = { w: Number(rest[0]), color: c };
|
| 114 |
+
used = 2;
|
| 115 |
+
} else if (head === 'glow') {
|
| 116 |
+
if (!rest[0]) return bad('glow needs a colour and a radius', SLIDER_CLAUSES.glow);
|
| 117 |
+
const c = col(rest[0]); if (c === null) return null;
|
| 118 |
+
spec.glow = { color: c, r: isNum(rest[1]) ? Number(rest[1]) : 12 };
|
| 119 |
+
used = isNum(rest[1]) ? 2 : 1;
|
| 120 |
+
} else if (head === 'ticks') {
|
| 121 |
+
if (!isNum(rest[0]) || Number(rest[0]) < 2) return bad('ticks needs a count of at least 2', SLIDER_CLAUSES.ticks);
|
| 122 |
+
let color = 'var(--dssl-color-muted)', len = 6;
|
| 123 |
+
for (const x of rest.slice(1)) { if (isNum(x)) len = Number(x); else { const c = col(x); if (c === null) return null; color = c; } }
|
| 124 |
+
spec.ticks = { n: Math.round(Number(rest[0])), color, len };
|
| 125 |
+
} else if (head === 'graph') {
|
| 126 |
+
const vals = rest.map(Number);
|
| 127 |
+
if (vals.length < 2 || vals.some((n) => Number.isNaN(n))) return bad('graph needs at least two numbers', SLIDER_CLAUSES.graph);
|
| 128 |
+
if (vals.some((n) => n < 0 || n > 100)) return bad('graph values are 0 to 100', SLIDER_CLAUSES.graph);
|
| 129 |
+
if (vals.length > 120) return bad('graph is limited to 120 bars', null);
|
| 130 |
+
spec.graph = vals;
|
| 131 |
+
} else if (head === 'graph-height') {
|
| 132 |
+
if (!isNum(rest[0]) || Number(rest[0]) < 8) return bad('graph-height needs a number of px, at least 8', null);
|
| 133 |
+
spec.graphHeight = Number(rest[0]);
|
| 134 |
+
used = 1;
|
| 135 |
+
} else if (head === 'graph-gap') {
|
| 136 |
+
if (!isNum(rest[0]) || Number(rest[0]) < 0) return bad('graph-gap needs a number of px', null);
|
| 137 |
+
spec.graphGap = Number(rest[0]);
|
| 138 |
+
used = 1;
|
| 139 |
+
}
|
| 140 |
+
if (used < rest.length) {
|
| 141 |
+
return bad(`unexpected "${rest.slice(used).join(' ')}" in the ${head} setting`, `separate settings with commas, e.g. thumb 16 #fff, shape bar. ${SLIDER_CLAUSES[head]}`);
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
return spec;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const f = (n) => String(+Number(n).toFixed(2));
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Build the CSS for a parsed slider.
|
| 151 |
+
* Returns { base: [{prop,value}], shadow: [{pseudo, decls:[{prop,value}]}] }
|
| 152 |
+
* `base` goes on the element itself, `shadow` are the vendor pseudo-element rules.
|
| 153 |
+
*/
|
| 154 |
+
export function sliderCss(spec) {
|
| 155 |
+
const T = spec.track.h;
|
| 156 |
+
const hasGraph = !!spec.graph;
|
| 157 |
+
const G = spec.graphHeight;
|
| 158 |
+
const trackH = hasGraph ? G : T;
|
| 159 |
+
const S = spec.thumb.size;
|
| 160 |
+
// thumb box by shape
|
| 161 |
+
let tw = S, th = S, tr = '0', rot = '';
|
| 162 |
+
if (spec.shape === 'circle') tr = '50%';
|
| 163 |
+
else if (spec.shape === 'square') tr = `${f(Math.min(6, S / 4))}px`;
|
| 164 |
+
else if (spec.shape === 'diamond') { tr = '2px'; rot = 'rotate(45deg)'; tw = th = S * 0.82; }
|
| 165 |
+
else if (spec.shape === 'pill') { tw = S * 0.7; th = S * 1.3; tr = '999px'; }
|
| 166 |
+
else if (spec.shape === 'bar') { tw = Math.max(4, S * 0.25); th = hasGraph ? G + 6 : S * 1.4; tr = '2px'; }
|
| 167 |
+
const ringW = spec.ring ? spec.ring.w : 0;
|
| 168 |
+
// a fully rounded track would clip a graph's bars into a semicircle, so graphs default to small corners
|
| 169 |
+
const trackRadius = spec.track.radius === 'full' ? (hasGraph ? '4px' : `${f(trackH / 2)}px`) : `${f(spec.track.radius)}px`;
|
| 170 |
+
const inputH = Math.max(trackH, th) + (spec.ticks ? spec.ticks.len + 2 : 0);
|
| 171 |
+
|
| 172 |
+
const p = 'var(--dssl-p, 0.5)';
|
| 173 |
+
// where the thumb centre is, as a length along the track
|
| 174 |
+
const along = `calc(${f(tw / 2)}px + ${p} * (100% - ${f(tw)}px))`;
|
| 175 |
+
const trackColor = spec.track.color;
|
| 176 |
+
const fill = spec.fill;
|
| 177 |
+
|
| 178 |
+
// ---- the track's background
|
| 179 |
+
const layers = []; // each: [image, size, position]
|
| 180 |
+
if (hasGraph) {
|
| 181 |
+
const n = spec.graph.length;
|
| 182 |
+
const lineH = Math.max(2, T);
|
| 183 |
+
const usable = G - lineH - 2;
|
| 184 |
+
spec.graph.forEach((v, i) => {
|
| 185 |
+
const x = n === 1 ? 50 : (i / (n - 1)) * 100;
|
| 186 |
+
const bw = `calc((100% - ${f((n - 1) * spec.graphGap)}px) / ${n})`;
|
| 187 |
+
const bh = Math.max(1, (usable * v) / 100);
|
| 188 |
+
const mixer = fill
|
| 189 |
+
? `color-mix(in srgb, ${fill} calc(clamp(0, (${p} - ${f((i + 0.5) / n)}) * 1000, 1) * 100%), ${trackColor})`
|
| 190 |
+
: trackColor;
|
| 191 |
+
layers.push([`linear-gradient(${mixer}, ${mixer})`, `${bw} ${f(bh)}px`, `${f(x)}% calc(100% - ${f(lineH + 2)}px)`]);
|
| 192 |
+
});
|
| 193 |
+
const line = fill ? `linear-gradient(to right, ${fill} 0, ${fill} ${along}, ${trackColor} ${along}, ${trackColor} 100%)` : `linear-gradient(${trackColor}, ${trackColor})`;
|
| 194 |
+
layers.push([line, `100% ${f(lineH)}px`, '0 100%']);
|
| 195 |
+
} else if (fill) {
|
| 196 |
+
layers.push([`linear-gradient(to right, ${fill} 0, ${fill} ${along}, ${trackColor} ${along}, ${trackColor} 100%)`, '100% 100%', '0 0']);
|
| 197 |
+
} else {
|
| 198 |
+
layers.push([`linear-gradient(${trackColor}, ${trackColor})`, '100% 100%', '0 0']);
|
| 199 |
+
}
|
| 200 |
+
const trackBg = [
|
| 201 |
+
{ prop: 'background-image', value: layers.map((l) => l[0]).join(', ') },
|
| 202 |
+
{ prop: 'background-size', value: layers.map((l) => l[1]).join(', ') },
|
| 203 |
+
{ prop: 'background-position', value: layers.map((l) => l[2]).join(', ') },
|
| 204 |
+
{ prop: 'background-repeat', value: 'no-repeat' },
|
| 205 |
+
];
|
| 206 |
+
|
| 207 |
+
const thumbCommon = [
|
| 208 |
+
{ prop: 'box-sizing', value: 'border-box' },
|
| 209 |
+
{ prop: 'width', value: `${f(tw)}px` },
|
| 210 |
+
{ prop: 'height', value: `${f(th)}px` },
|
| 211 |
+
{ prop: 'background', value: spec.thumb.color },
|
| 212 |
+
{ prop: 'border-radius', value: tr },
|
| 213 |
+
{ prop: 'border', value: ringW ? `${f(ringW)}px solid ${spec.ring.color}` : '0' },
|
| 214 |
+
{ prop: 'cursor', value: 'pointer' },
|
| 215 |
+
];
|
| 216 |
+
const glow = spec.glow ? [{ prop: 'box-shadow', value: `0 0 ${f(spec.glow.r)}px ${f(spec.glow.r / 3)}px ${spec.glow.color}` }] : [];
|
| 217 |
+
if (rot) thumbCommon.push({ prop: 'transform', value: rot });
|
| 218 |
+
|
| 219 |
+
const base = [
|
| 220 |
+
{ prop: 'appearance', value: 'none' },
|
| 221 |
+
{ prop: '-webkit-appearance', value: 'none' },
|
| 222 |
+
{ prop: 'background-color', value: 'transparent' },
|
| 223 |
+
{ prop: 'margin', value: '0' },
|
| 224 |
+
{ prop: 'width', value: '100%' },
|
| 225 |
+
{ prop: 'height', value: `${f(inputH)}px` },
|
| 226 |
+
{ prop: 'cursor', value: 'pointer' },
|
| 227 |
+
];
|
| 228 |
+
if (spec.ticks) {
|
| 229 |
+
const n = spec.ticks.n;
|
| 230 |
+
base.push(
|
| 231 |
+
{ prop: 'background-image', value: `repeating-linear-gradient(to right, ${spec.ticks.color} 0, ${spec.ticks.color} 1px, transparent 1px, transparent calc((100% - 1px) / ${n - 1}))` },
|
| 232 |
+
{ prop: 'background-size', value: `calc(100% - ${f(tw)}px) ${f(spec.ticks.len)}px` },
|
| 233 |
+
{ prop: 'background-position', value: `center bottom` },
|
| 234 |
+
{ prop: 'background-repeat', value: 'no-repeat' },
|
| 235 |
+
);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
const shadow = [
|
| 239 |
+
{ pseudo: '-webkit-slider-runnable-track', decls: [{ prop: 'height', value: `${f(trackH)}px` }, { prop: 'border-radius', value: trackRadius }, ...trackBg] },
|
| 240 |
+
{ pseudo: '-webkit-slider-thumb', decls: [
|
| 241 |
+
{ prop: '-webkit-appearance', value: 'none' }, { prop: 'appearance', value: 'none' }, ...thumbCommon, ...glow,
|
| 242 |
+
{ prop: 'margin-top', value: `${f((trackH - th) / 2)}px` },
|
| 243 |
+
] },
|
| 244 |
+
{ pseudo: '-moz-range-track', decls: [{ prop: 'height', value: `${f(trackH)}px` }, { prop: 'border-radius', value: trackRadius }, ...trackBg] },
|
| 245 |
+
{ pseudo: '-moz-range-thumb', decls: [...thumbCommon, ...glow] },
|
| 246 |
+
// Firefox can fill natively, but we paint the fill ourselves (above) so both engines match
|
| 247 |
+
{ pseudo: '-moz-range-progress', decls: [{ prop: 'background', value: 'transparent' }, { prop: 'height', value: `${f(trackH)}px` }] },
|
| 248 |
+
];
|
| 249 |
+
return { base, shadow };
|
| 250 |
+
}
|
dssl-vscode/lib/engine/texture.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL: texture.js
|
| 3 |
+
*
|
| 4 |
+
* .card { texture: paper }
|
| 5 |
+
* .metal { texture: brushed intensity 0.6 color2 #cfd8e3 }
|
| 6 |
+
* .mine { texture: noise scale 3 octaves 4 color #2a1a0a color2 #c08040 blend multiply }
|
| 7 |
+
*
|
| 8 |
+
* A texture is a LAYER of generated image that sits on top of an element's own
|
| 9 |
+
* background. There are generators (noise, bump, lines, dots, grid, checker,
|
| 10 |
+
* weave, carbon, waves, hex) and settings (scale, intensity, contrast, color,
|
| 11 |
+
* color2, blend, ...). Every named preset below is nothing more than a stored
|
| 12 |
+
* combination of those, so anything a preset does you can write yourself, and
|
| 13 |
+
* you can name your own with a `behavior` block.
|
| 14 |
+
*
|
| 15 |
+
* This file is pure: no DOM. parseTexture() runs in the compiler and checks the
|
| 16 |
+
* words; buildTexture() turns a parsed layer into CSS background pieces and is
|
| 17 |
+
* given a function that resolves colour words (the engine passes one that
|
| 18 |
+
* understands the page's tokens; tests pass parseColor).
|
| 19 |
+
* ========================================================================= */
|
| 20 |
+
|
| 21 |
+
import { parseColor, withAlpha, toRgba, mix } from './color.js';
|
| 22 |
+
|
| 23 |
+
const mix2 = (x, y, t) => mix(x, y, t, 'srgb');
|
| 24 |
+
|
| 25 |
+
/* ------------------------------------------------------------------ *
|
| 26 |
+
* vocabulary
|
| 27 |
+
* ------------------------------------------------------------------ */
|
| 28 |
+
|
| 29 |
+
export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
|
| 30 |
+
|
| 31 |
+
export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex'];
|
| 32 |
+
|
| 33 |
+
/** the settings a texture layer understands, with how many value words each takes */
|
| 34 |
+
export const TEXTURE_KEYS = {
|
| 35 |
+
scale: { words: 1, kind: 'number', min: 0.1, max: 2000, doc: 'feature size in px: how big the grains, cells or lines are' },
|
| 36 |
+
intensity: { words: 1, kind: 'number', min: 0, max: 2, doc: 'how strong the layer is, 0 to 1 (more than 1 over-drives it)' },
|
| 37 |
+
contrast: { words: 1, kind: 'number', min: 0, max: 40, doc: 'noise contrast: 1 is as generated, higher is harsher' },
|
| 38 |
+
threshold: { words: 1, kind: 'number', min: 0, max: 1, doc: 'noise cut-off: only values above it show (glitter uses a high one)' },
|
| 39 |
+
contour: { words: 1, kind: 'number', min: 0.005, max: 0.5, doc: 'draw thin lines where the noise crosses the threshold, instead of a plain cut: veins, rings, contours' },
|
| 40 |
+
octaves: { words: 1, kind: 'number', min: 1, max: 8, int: true, doc: 'noise detail layers, 1 is smooth, 5 is rough' },
|
| 41 |
+
seed: { words: 1, kind: 'number', min: 0, max: 99999, int: true, doc: 'a different random pattern with the same look' },
|
| 42 |
+
stretch: { words: 2, kind: 'numbers', min: 0.1, max: 200, doc: 'stretch noise features along x and y, e.g. stretch 40 1 for wood grain' },
|
| 43 |
+
depth: { words: 1, kind: 'number', min: 0, max: 30, doc: 'bump height: how deep the relief looks' },
|
| 44 |
+
light: { words: 1, kind: 'number', min: -360, max: 360, doc: 'bump light direction in degrees' },
|
| 45 |
+
angle: { words: 1, kind: 'number', min: -360, max: 360, doc: 'direction of lines and patterns, in degrees' },
|
| 46 |
+
thickness: { words: 1, kind: 'number', min: 0.1, max: 500, doc: 'line or grid-line width in px' },
|
| 47 |
+
radius: { words: 1, kind: 'number', min: 0.02, max: 1, doc: 'dot size as a fraction of the cell' },
|
| 48 |
+
softness: { words: 1, kind: 'number', min: 0, max: 1, doc: 'how soft the edges of dots and lines are' },
|
| 49 |
+
animate: { words: 1, kind: 'number', min: 0, max: 60000, doc: 'milliseconds per shimmer step (film-grain flicker); 0 is still' },
|
| 50 |
+
type: { words: 1, kind: 'word', values: ['fractal', 'turbulence'], doc: 'noise flavour: fractal is clouds, turbulence is veins' },
|
| 51 |
+
blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
|
| 52 |
+
color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
|
| 53 |
+
color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
|
| 54 |
+
invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
/* ------------------------------------------------------------------ *
|
| 58 |
+
* presets: a name is a stored set of settings, nothing more
|
| 59 |
+
* layers: one or more { gen, ...settings }. First entries sit on top.
|
| 60 |
+
* ------------------------------------------------------------------ */
|
| 61 |
+
|
| 62 |
+
const K = (r, g, b, a = 1) => `rgba(${r},${g},${b},${a})`;
|
| 63 |
+
|
| 64 |
+
export const PRESETS = {
|
| 65 |
+
// ---- grains and noise
|
| 66 |
+
grain: { doc: 'fine film grain, neutral on any colour', mask: true,
|
| 67 |
+
layers: [{ gen: 'noise', scale: 1.1, octaves: 2, contrast: 1.7, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 68 |
+
noise: { doc: 'coarser static', mask: true,
|
| 69 |
+
layers: [{ gen: 'noise', scale: 2.6, octaves: 3, contrast: 1.9, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 70 |
+
rough: { doc: 'gritty relief, like a rough cast wall', mask: true,
|
| 71 |
+
layers: [{ gen: 'bump', scale: 3, octaves: 4, depth: 3.4, light: 135, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 72 |
+
sand: { doc: 'warm fine sand grains', mask: true,
|
| 73 |
+
layers: [{ gen: 'bump', scale: 1.5, octaves: 3, depth: 2.2, light: 130, intensity: 0.7, color: '#5a4426', color2: '#fff1d0', blend: 'overlay' }] },
|
| 74 |
+
paper: { doc: 'soft paper fibre with a little unevenness',
|
| 75 |
+
layers: [
|
| 76 |
+
{ gen: 'noise', scale: 1.5, octaves: 4, contrast: 1.2, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
|
| 77 |
+
{ gen: 'noise', scale: 120, octaves: 3, contrast: 1.3, intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 7 },
|
| 78 |
+
] },
|
| 79 |
+
cloud: { doc: 'big soft cloudy variation', mask: true,
|
| 80 |
+
layers: [{ gen: 'noise', scale: 90, octaves: 5, contrast: 1.6, intensity: 0.7, color: '#000', color2: '#fff', blend: 'soft-light' }] },
|
| 81 |
+
concrete: { doc: 'pitted concrete',
|
| 82 |
+
layers: [
|
| 83 |
+
{ gen: 'bump', scale: 6, octaves: 5, depth: 2.6, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay' },
|
| 84 |
+
{ gen: 'noise', scale: 140, octaves: 4, contrast: 1.5, intensity: 0.45, color: '#000', color2: '#fff', blend: 'soft-light', seed: 3 },
|
| 85 |
+
] },
|
| 86 |
+
stone: { doc: 'carved stone with broad relief',
|
| 87 |
+
layers: [{ gen: 'bump', scale: 26, octaves: 5, depth: 2.4, light: 125, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 88 |
+
crumpled: { doc: 'crumpled foil or paper, big creases',
|
| 89 |
+
layers: [{ gen: 'bump', scale: 34, octaves: 4, depth: 4, light: 135, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay', seed: 11 }] },
|
| 90 |
+
glitter: { doc: 'sparse bright sparkles', mask: true,
|
| 91 |
+
layers: [{ gen: 'noise', scale: 1.2, octaves: 1, contrast: 16, threshold: 0.66, intensity: 1, color: 'rgba(255,255,255,0)', color2: '#fff', blend: 'screen', seed: 5 }] },
|
| 92 |
+
frost: { doc: 'fine icy crystals',
|
| 93 |
+
layers: [{ gen: 'bump', scale: 2.2, octaves: 4, depth: 3.2, light: 140, intensity: 0.95, color: 'rgba(160,210,255,0)', color2: '#ffffff', blend: 'screen' }] },
|
| 94 |
+
// ---- natural, stretched
|
| 95 |
+
marble: { doc: 'veined marble',
|
| 96 |
+
layers: [
|
| 97 |
+
{ gen: 'noise', scale: 120, octaves: 5, contour: 0.035, threshold: 0.5, intensity: 0.9, color: '#6c6877', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
|
| 98 |
+
{ gen: 'noise', scale: 120, octaves: 5, contour: 0.16, threshold: 0.5, intensity: 0.25, color: '#9b97a6', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
|
| 99 |
+
{ gen: 'noise', scale: 200, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#cfc9c0', color2: '#fbfaf7', blend: 'soft-light', seed: 21 },
|
| 100 |
+
] },
|
| 101 |
+
wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
|
| 102 |
+
layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
|
| 103 |
+
bark: { doc: 'rough vertical bark',
|
| 104 |
+
layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 105 |
+
leather: { doc: 'pebbled leather',
|
| 106 |
+
layers: [{ gen: 'bump', scale: 5, octaves: 2, depth: 4.2, light: 120, intensity: 0.85, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 107 |
+
brushed: { doc: 'brushed metal, fine lines along x',
|
| 108 |
+
layers: [{ gen: 'noise', scale: 1.2, octaves: 2, contrast: 1.5, stretch: [60, 1], intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
|
| 109 |
+
// ---- woven
|
| 110 |
+
canvas: { doc: 'artist canvas weave',
|
| 111 |
+
layers: [
|
| 112 |
+
{ gen: 'weave', scale: 3, thickness: 1, intensity: 0.5, color: '#000', blend: 'multiply' },
|
| 113 |
+
{ gen: 'noise', scale: 1.4, octaves: 3, contrast: 1.3, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
|
| 114 |
+
] },
|
| 115 |
+
linen: { doc: 'loose linen weave with slubs',
|
| 116 |
+
layers: [
|
| 117 |
+
{ gen: 'weave', scale: 4, thickness: 1.2, intensity: 0.4, color: '#5a4a32', blend: 'multiply' },
|
| 118 |
+
{ gen: 'noise', scale: 80, octaves: 3, contrast: 1.4, stretch: [8, 1], intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 9 },
|
| 119 |
+
] },
|
| 120 |
+
denim: { doc: 'diagonal twill, blue by default',
|
| 121 |
+
layers: [
|
| 122 |
+
{ gen: 'lines', angle: 45, scale: 3, thickness: 1.4, intensity: 0.45, color: '#0d2147', blend: 'multiply' },
|
| 123 |
+
{ gen: 'noise', scale: 1.3, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay', seed: 2 },
|
| 124 |
+
] },
|
| 125 |
+
carbon: { doc: 'carbon fibre twill',
|
| 126 |
+
layers: [{ gen: 'carbon', scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' }] },
|
| 127 |
+
// ---- patterns
|
| 128 |
+
scanlines: { doc: 'CRT scanlines',
|
| 129 |
+
layers: [{ gen: 'lines', angle: 0, scale: 4, thickness: 2, intensity: 0.28, color: '#000', blend: 'normal' }] },
|
| 130 |
+
halftone: { doc: 'newsprint dots',
|
| 131 |
+
layers: [{ gen: 'dots', scale: 8, radius: 0.2, softness: 0.2, intensity: 0.55, color: '#000', blend: 'multiply', stagger: true }] },
|
| 132 |
+
dots: { doc: 'a plain dot grid',
|
| 133 |
+
layers: [{ gen: 'dots', scale: 14, radius: 0.16, softness: 0.05, intensity: 0.35, color: '#000', blend: 'normal' }] },
|
| 134 |
+
lines: { doc: 'diagonal hairlines',
|
| 135 |
+
layers: [{ gen: 'lines', angle: 45, scale: 8, thickness: 1, intensity: 0.3, color: '#000', blend: 'normal' }] },
|
| 136 |
+
grid: { doc: 'a square grid, like graph paper',
|
| 137 |
+
layers: [{ gen: 'grid', scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' }] },
|
| 138 |
+
checker: { doc: 'checkerboard',
|
| 139 |
+
layers: [{ gen: 'checker', scale: 24, intensity: 0.12, color: '#000', blend: 'normal' }] },
|
| 140 |
+
crosshatch: { doc: 'crossed hairlines',
|
| 141 |
+
layers: [
|
| 142 |
+
{ gen: 'lines', angle: 45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
|
| 143 |
+
{ gen: 'lines', angle: -45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
|
| 144 |
+
] },
|
| 145 |
+
stripes: { doc: 'bold diagonal stripes',
|
| 146 |
+
layers: [{ gen: 'lines', angle: 45, scale: 32, thickness: 16, intensity: 0.12, color: '#000', blend: 'normal' }] },
|
| 147 |
+
waves: { doc: 'soft repeating waves',
|
| 148 |
+
layers: [{ gen: 'waves', scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' }] },
|
| 149 |
+
hex: { doc: 'honeycomb',
|
| 150 |
+
layers: [{ gen: 'hex', scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' }] },
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
export const PRESET_NAMES = Object.keys(PRESETS);
|
| 154 |
+
|
| 155 |
+
/** baseline settings per generator, used when you start from a bare generator name */
|
| 156 |
+
const GEN_DEFAULTS = {
|
| 157 |
+
noise: { scale: 3, octaves: 3, contrast: 1.5, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay', type: 'fractal' },
|
| 158 |
+
bump: { scale: 6, octaves: 4, depth: 3, light: 135, intensity: 0.6, color: '#000', color2: '#fff', blend: 'overlay' },
|
| 159 |
+
lines: { scale: 8, thickness: 1, angle: 45, intensity: 0.3, color: '#000', blend: 'normal' },
|
| 160 |
+
dots: { scale: 12, radius: 0.2, softness: 0.1, intensity: 0.4, color: '#000', blend: 'normal' },
|
| 161 |
+
grid: { scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' },
|
| 162 |
+
checker: { scale: 24, intensity: 0.15, color: '#000', blend: 'normal' },
|
| 163 |
+
weave: { scale: 4, thickness: 1, intensity: 0.4, color: '#000', blend: 'multiply' },
|
| 164 |
+
carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
|
| 165 |
+
waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
|
| 166 |
+
hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
/* ------------------------------------------------------------------ *
|
| 170 |
+
* parsing (compile time)
|
| 171 |
+
* ------------------------------------------------------------------ */
|
| 172 |
+
|
| 173 |
+
/** split on top-level commas ("grain, scanlines" or "rgb(1, 2, 3)" stays whole) */
|
| 174 |
+
function splitCommas(s) {
|
| 175 |
+
const out = []; let depth = 0, cur = '';
|
| 176 |
+
for (const ch of s) {
|
| 177 |
+
if (ch === '(') depth++;
|
| 178 |
+
if (ch === ')') depth--;
|
| 179 |
+
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
|
| 180 |
+
cur += ch;
|
| 181 |
+
}
|
| 182 |
+
if (cur.trim()) out.push(cur.trim());
|
| 183 |
+
return out;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/** split on top-level whitespace, keeping rgb(1 2 3) and color.ink/40 as one word */
|
| 187 |
+
export function splitWords(s) {
|
| 188 |
+
const out = []; let depth = 0, cur = '';
|
| 189 |
+
for (const ch of s.trim()) {
|
| 190 |
+
if (ch === '(') depth++;
|
| 191 |
+
if (ch === ')') depth--;
|
| 192 |
+
if (/\s/.test(ch) && depth === 0) { if (cur) out.push(cur); cur = ''; continue; }
|
| 193 |
+
cur += ch;
|
| 194 |
+
}
|
| 195 |
+
if (cur) out.push(cur);
|
| 196 |
+
return out;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* Parse the value of `texture:`.
|
| 201 |
+
* hooks.error(message, hint) report a problem
|
| 202 |
+
* hooks.color(word) turn a colour word into the CSS the engine will resolve later
|
| 203 |
+
* hooks.suggest(word, list) did-you-mean
|
| 204 |
+
* Returns an array of layer specs, [] for `none`, or null when it was invalid.
|
| 205 |
+
*/
|
| 206 |
+
export function parseTexture(value, hooks) {
|
| 207 |
+
const text = String(value || '').trim();
|
| 208 |
+
if (!text) { hooks.error('texture needs a name', `try texture: grain, or one of ${PRESET_NAMES.slice(0, 6).join(', ')} ...`); return null; }
|
| 209 |
+
if (text === 'none') return [];
|
| 210 |
+
const out = [];
|
| 211 |
+
for (const part of splitCommas(text)) {
|
| 212 |
+
const words = splitWords(part);
|
| 213 |
+
const first = words[0];
|
| 214 |
+
let base;
|
| 215 |
+
if (PRESETS[first]) base = PRESETS[first].layers.map((l) => ({ ...l, preset: first }));
|
| 216 |
+
else if (GENERATORS.includes(first)) base = [{ gen: first, preset: null, ...GEN_DEFAULTS[first] }];
|
| 217 |
+
else {
|
| 218 |
+
hooks.error(`unknown texture "${first}"`, hooks.suggest(first, [...PRESET_NAMES, ...GENERATORS]) || `presets: ${PRESET_NAMES.join(', ')}. generators: ${GENERATORS.join(', ')}`);
|
| 219 |
+
return null;
|
| 220 |
+
}
|
| 221 |
+
const over = {};
|
| 222 |
+
for (let i = 1; i < words.length; i++) {
|
| 223 |
+
const key = words[i];
|
| 224 |
+
const def = TEXTURE_KEYS[key];
|
| 225 |
+
if (!def) {
|
| 226 |
+
hooks.error(`unknown texture setting "${key}"`, hooks.suggest(key, Object.keys(TEXTURE_KEYS)) || `settings: ${Object.keys(TEXTURE_KEYS).join(', ')}`);
|
| 227 |
+
return null;
|
| 228 |
+
}
|
| 229 |
+
if (def.kind === 'flag') { over[key] = true; continue; }
|
| 230 |
+
const vals = words.slice(i + 1, i + 1 + def.words);
|
| 231 |
+
if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
|
| 232 |
+
hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
|
| 233 |
+
return null;
|
| 234 |
+
}
|
| 235 |
+
i += def.words;
|
| 236 |
+
if (def.kind === 'number' || def.kind === 'numbers') {
|
| 237 |
+
const nums = vals.map((v) => Number(v));
|
| 238 |
+
if (nums.some((n) => Number.isNaN(n))) { hooks.error(`texture setting "${key}" needs a number, got "${vals.join(' ')}"`, def.doc); return null; }
|
| 239 |
+
if (nums.some((n) => n < def.min || n > def.max)) { hooks.error(`texture "${key}" must be between ${def.min} and ${def.max}`, def.doc); return null; }
|
| 240 |
+
over[key] = def.kind === 'numbers' ? nums : (def.int ? Math.round(nums[0]) : nums[0]);
|
| 241 |
+
} else if (def.kind === 'word') {
|
| 242 |
+
if (!def.values.includes(vals[0])) {
|
| 243 |
+
hooks.error(`unknown ${key} "${vals[0]}"`, hooks.suggest(vals[0], def.values) || `use one of: ${def.values.join(', ')}`);
|
| 244 |
+
return null;
|
| 245 |
+
}
|
| 246 |
+
over[key] = vals[0];
|
| 247 |
+
} else if (def.kind === 'color') {
|
| 248 |
+
const css = hooks.color(vals[0]);
|
| 249 |
+
if (css === null) return null;
|
| 250 |
+
over[key] = css;
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
// settings given in a custom texture apply to every layer of a multi-layer preset
|
| 254 |
+
// (color settings apply to the first layer only when the preset has several, so a
|
| 255 |
+
// two-layer preset keeps its own second tone)
|
| 256 |
+
base.forEach((layer, idx) => {
|
| 257 |
+
const merged = { ...layer };
|
| 258 |
+
for (const [k, v] of Object.entries(over)) {
|
| 259 |
+
if ((k === 'color' || k === 'color2') && base.length > 1 && idx > 0) continue;
|
| 260 |
+
merged[k] = v;
|
| 261 |
+
}
|
| 262 |
+
out.push(merged);
|
| 263 |
+
});
|
| 264 |
+
}
|
| 265 |
+
return out;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* ------------------------------------------------------------------ *
|
| 269 |
+
* building (run time, in the browser, or in tests)
|
| 270 |
+
* ------------------------------------------------------------------ */
|
| 271 |
+
|
| 272 |
+
const svgUrl = (svg) => `url("data:image/svg+xml,${encodeURIComponent(svg).replace(/'/g, '%27')}")`;
|
| 273 |
+
const f = (n, d = 3) => String(+Number(n).toFixed(d));
|
| 274 |
+
const n01 = (x) => Math.max(0, Math.min(1, x));
|
| 275 |
+
|
| 276 |
+
/** resolve a layer's two colours into rgba objects with intensity applied */
|
| 277 |
+
function layerColors(spec, rc) {
|
| 278 |
+
let a = rc(spec.color) || parseColor('#000');
|
| 279 |
+
let b = rc(spec.color2) || (spec.color2 === undefined ? null : parseColor('#fff'));
|
| 280 |
+
const k = spec.intensity === undefined ? 1 : spec.intensity;
|
| 281 |
+
if (spec.invert && b) [a, b] = [b, a];
|
| 282 |
+
const scaleA = (c) => (c ? withAlpha(c, n01(c.a * k)) : null);
|
| 283 |
+
return { a: scaleA(a), b: scaleA(b), raw: { a, b } };
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function noiseFreq(spec) {
|
| 287 |
+
const base = 1 / Math.max(0.2, spec.scale || 3);
|
| 288 |
+
const st = spec.stretch || [1, 1];
|
| 289 |
+
let fx = base / (st[0] || 1), fy = base / (st[1] || 1);
|
| 290 |
+
if (spec.angle === 90 || spec.angle === -90 || spec.angle === 270) [fx, fy] = [fy, fx];
|
| 291 |
+
return [Math.max(0.0008, fx), Math.max(0.0008, fy)];
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const tileFor = (spec) => {
|
| 295 |
+
const f2 = noiseFreq(spec);
|
| 296 |
+
// keep tiles reasonably small, but big enough to hide repetition for coarse noise
|
| 297 |
+
const feature = 1 / Math.min(f2[0], f2[1]);
|
| 298 |
+
return Math.round(Math.max(128, Math.min(512, feature * 3)));
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
/** a lookup table that is 0 right at `level` and rises to 1 by `width` away: contour lines */
|
| 302 |
+
function contourTable(level, width) {
|
| 303 |
+
const n = 96, out = [];
|
| 304 |
+
for (let i = 0; i < n; i++) out.push(f(Math.min(1, Math.abs(i / (n - 1) - level) / width), 3));
|
| 305 |
+
return out.join(' ');
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
/** noise → contrast curve → two-colour ramp. The workhorse. */
|
| 309 |
+
function buildNoise(spec, rc) {
|
| 310 |
+
const { a, b } = layerColors(spec, rc);
|
| 311 |
+
const A = a, B = b || a;
|
| 312 |
+
const [fx, fy] = noiseFreq(spec);
|
| 313 |
+
const oct = Math.max(1, Math.round(spec.octaves || 3));
|
| 314 |
+
const c = spec.contrast === undefined ? 1.5 : spec.contrast;
|
| 315 |
+
const t = spec.threshold === undefined ? 0.5 : spec.threshold;
|
| 316 |
+
const off = 0.5 - c * t;
|
| 317 |
+
const T = tileFor(spec);
|
| 318 |
+
const ch = (x) => f(x / 255);
|
| 319 |
+
const svg =
|
| 320 |
+
`<svg xmlns='http://www.w3.org/2000/svg' width='${T}' height='${T}'>` +
|
| 321 |
+
`<filter id='f' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
|
| 322 |
+
`<feTurbulence type='${spec.type === 'turbulence' ? 'turbulence' : 'fractalNoise'}' baseFrequency='${f(fx, 5)} ${f(fy, 5)}' numOctaves='${oct}' seed='${spec.seed || 0}' stitchTiles='stitch'/>` +
|
| 323 |
+
(spec.contour
|
| 324 |
+
? `<feColorMatrix type='matrix' values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/>` +
|
| 325 |
+
`<feComponentTransfer><feFuncR type='table' tableValues='${contourTable(t, spec.contour)}'/><feFuncG type='table' tableValues='${contourTable(t, spec.contour)}'/><feFuncB type='table' tableValues='${contourTable(t, spec.contour)}'/></feComponentTransfer>`
|
| 326 |
+
: `<feColorMatrix type='matrix' values='${f(c)} 0 0 0 ${f(off)} ${f(c)} 0 0 0 ${f(off)} ${f(c)} 0 0 0 ${f(off)} 0 0 0 0 1'/>`) +
|
| 327 |
+
`<feColorMatrix type='matrix' values='${f(ch(B.r - A.r))} 0 0 0 ${ch(A.r)} ${f(ch(B.g - A.g))} 0 0 0 ${ch(A.g)} ${f(ch(B.b - A.b))} 0 0 0 ${ch(A.b)} ${f(B.a - A.a)} 0 0 0 ${f(A.a)}'/>` +
|
| 328 |
+
`</filter><rect width='100%' height='100%' filter='url(#f)'/></svg>`;
|
| 329 |
+
return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
/**
|
| 333 |
+
* Noise used as a height map and lit from one side, so it reads as physical
|
| 334 |
+
* relief. The lighting is a 3x3 directional-derivative convolution with
|
| 335 |
+
* edgeMode="wrap", which keeps the tile seamless. feDiffuseLighting looks
|
| 336 |
+
* nicer up close but treats the tile edge specially and leaves a 1px seam.
|
| 337 |
+
*/
|
| 338 |
+
function buildBump(spec, rc) {
|
| 339 |
+
const { a, b } = layerColors(spec, rc);
|
| 340 |
+
const A = a, B = b || a;
|
| 341 |
+
const [fx, fy] = noiseFreq(spec);
|
| 342 |
+
const oct = Math.max(1, Math.round(spec.octaves || 4));
|
| 343 |
+
const T = tileFor(spec);
|
| 344 |
+
const az = ((spec.light === undefined ? 135 : spec.light) * Math.PI) / 180;
|
| 345 |
+
// relief strength grows slowly with feature size: big features have gentle slopes, but the fine
|
| 346 |
+
// octaves layered on top keep their steep ones, so a linear rule over-drives anything coarse
|
| 347 |
+
const m = (spec.depth === undefined ? 3 : spec.depth) * 0.33 * Math.pow(Math.max(0.5, spec.scale || 6), 0.25);
|
| 348 |
+
const gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1], gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
|
| 349 |
+
const kernel = gx.map((v, i) => f((m * (Math.cos(az) * v + Math.sin(az) * gy[i])) / 4, 4)).join(' ');
|
| 350 |
+
const ch = (x) => f(x / 255);
|
| 351 |
+
const svg =
|
| 352 |
+
`<svg xmlns='http://www.w3.org/2000/svg' width='${T}' height='${T}'>` +
|
| 353 |
+
`<filter id='f' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'>` +
|
| 354 |
+
`<feTurbulence type='fractalNoise' baseFrequency='${f(fx, 5)} ${f(fy, 5)}' numOctaves='${oct}' seed='${spec.seed || 0}' stitchTiles='stitch'/>` +
|
| 355 |
+
`<feColorMatrix type='matrix' values='1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1'/>` +
|
| 356 |
+
`<feConvolveMatrix order='3' kernelMatrix='${kernel}' divisor='1' bias='0.5' edgeMode='wrap' preserveAlpha='true'/>` +
|
| 357 |
+
`<feColorMatrix type='matrix' values='${f(ch(B.r - A.r))} 0 0 0 ${ch(A.r)} ${f(ch(B.g - A.g))} 0 0 0 ${ch(A.g)} ${f(ch(B.b - A.b))} 0 0 0 ${ch(A.b)} ${f(B.a - A.a)} 0 0 0 ${f(A.a)}'/>` +
|
| 358 |
+
`</filter><rect width='100%' height='100%' filter='url(#f)'/></svg>`;
|
| 359 |
+
return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
function buildLines(spec, rc) {
|
| 363 |
+
const { a, b } = layerColors(spec, rc);
|
| 364 |
+
const p = Math.max(1, spec.scale || 8), w = Math.min(p, Math.max(0.2, spec.thickness || 1));
|
| 365 |
+
const soft = (spec.softness === undefined ? 0.4 : spec.softness) * 1.5;
|
| 366 |
+
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
|
| 367 |
+
const ang = spec.angle === undefined ? 45 : spec.angle;
|
| 368 |
+
const image = `repeating-linear-gradient(${f(-ang, 1)}deg, ${ink} 0, ${ink} ${f(w)}px, ${bg} ${f(w + soft)}px, ${bg} ${f(p)}px)`;
|
| 369 |
+
return [{ image, size: 'auto', repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: p }];
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
function buildDots(spec, rc) {
|
| 373 |
+
const { a, b } = layerColors(spec, rc);
|
| 374 |
+
const S = Math.max(2, spec.scale || 12), r = (spec.radius === undefined ? 0.2 : spec.radius) * S;
|
| 375 |
+
const soft = Math.max(0.3, (spec.softness === undefined ? 0.1 : spec.softness) * S);
|
| 376 |
+
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
|
| 377 |
+
const dot = `radial-gradient(circle at center, ${ink} 0, ${ink} ${f(Math.max(0, r - soft / 2))}px, ${bg} ${f(r + soft / 2)}px, ${bg} 100%)`;
|
| 378 |
+
const layers = [{ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
|
| 379 |
+
if (spec.stagger) layers.push({ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: `${f(S / 2)}px ${f(S / 2)}px`, blend: spec.blend || 'normal', tile: S });
|
| 380 |
+
return layers;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
function buildGrid(spec, rc) {
|
| 384 |
+
const { a, b } = layerColors(spec, rc);
|
| 385 |
+
const S = Math.max(3, spec.scale || 24), w = Math.max(0.3, spec.thickness || 1);
|
| 386 |
+
const ink = toRgba(a);
|
| 387 |
+
const blend = spec.blend || 'normal';
|
| 388 |
+
const out = [
|
| 389 |
+
{ image: `linear-gradient(${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
|
| 390 |
+
{ image: `linear-gradient(90deg, ${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
|
| 391 |
+
];
|
| 392 |
+
if (b) out.push({ image: `linear-gradient(${toRgba(b)}, ${toRgba(b)})`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: S });
|
| 393 |
+
return out;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
function buildChecker(spec, rc) {
|
| 397 |
+
const { a, b } = layerColors(spec, rc);
|
| 398 |
+
const S = Math.max(2, spec.scale || 24);
|
| 399 |
+
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
|
| 400 |
+
const image = `conic-gradient(${ink} 25%, ${bg} 0 50%, ${ink} 0 75%, ${bg} 0)`;
|
| 401 |
+
return [{ image, size: `${f(S * 2)}px ${f(S * 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S * 2 }];
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/** plain-weave threads: horizontal and vertical fine lines */
|
| 405 |
+
function buildWeave(spec, rc) {
|
| 406 |
+
const { a } = layerColors(spec, rc);
|
| 407 |
+
const p = Math.max(1.5, spec.scale || 4), w = Math.max(0.4, spec.thickness || 1);
|
| 408 |
+
const ink = toRgba(a), clear = toRgba(withAlpha(a.r === undefined ? parseColor('#000') : a, 0));
|
| 409 |
+
const blend = spec.blend || 'multiply';
|
| 410 |
+
return [
|
| 411 |
+
{ image: `repeating-linear-gradient(0deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
|
| 412 |
+
{ image: `repeating-linear-gradient(90deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
|
| 413 |
+
];
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
/** the classic layered-gradient carbon fibre, recoloured from color and color2 */
|
| 417 |
+
function buildCarbon(spec, rc) {
|
| 418 |
+
const { a, b } = layerColors(spec, rc);
|
| 419 |
+
const A = a, B = b || a;
|
| 420 |
+
const dark = toRgba(A), light = toRgba(B);
|
| 421 |
+
const mid = toRgba(mix2(A, B, 0.5)), deep = toRgba(mix2(A, parseColor('#000'), 0.25));
|
| 422 |
+
const S = Math.max(8, spec.scale || 20), h = S / 2, q = S / 4;
|
| 423 |
+
const blend = spec.blend || 'normal';
|
| 424 |
+
const sz = `${f(S)}px ${f(S)}px`;
|
| 425 |
+
const L = (image, size, position) => ({ image, size, repeat: 'repeat', position, blend, tile: S });
|
| 426 |
+
return [
|
| 427 |
+
L(`linear-gradient(27deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(q)}px`),
|
| 428 |
+
L(`linear-gradient(207deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px 0`),
|
| 429 |
+
L(`linear-gradient(27deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(h)}px`),
|
| 430 |
+
L(`linear-gradient(207deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px ${f(q)}px`),
|
| 431 |
+
L(`linear-gradient(90deg, ${mid} ${f(h)}px, transparent ${f(h)}px)`, sz, '0 0'),
|
| 432 |
+
L(`linear-gradient(${light} 25%, ${dark} 25%, ${dark} 50%, transparent 50%, transparent 75%, ${mid} 75%, ${mid})`, sz, '0 0'),
|
| 433 |
+
L(`linear-gradient(${deep}, ${deep})`, 'auto', '0 0'),
|
| 434 |
+
];
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
function buildWaves(spec, rc) {
|
| 438 |
+
const { a } = layerColors(spec, rc);
|
| 439 |
+
const S = Math.max(8, spec.scale || 40), w = Math.max(0.3, spec.thickness || 1.2);
|
| 440 |
+
const col = toRgba(a);
|
| 441 |
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${f(S)}' height='${f(S / 2)}' viewBox='0 0 ${f(S)} ${f(S / 2)}'>` +
|
| 442 |
+
`<path d='M0 ${f(S / 4)} Q ${f(S / 4)} 0 ${f(S / 2)} ${f(S / 4)} T ${f(S)} ${f(S / 4)}' fill='none' stroke='${col}' stroke-width='${f(w)}'/></svg>`;
|
| 443 |
+
return [{ image: svgUrl(svg), size: `${f(S)}px ${f(S / 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
function buildHex(spec, rc) {
|
| 447 |
+
const { a } = layerColors(spec, rc);
|
| 448 |
+
const W = Math.max(14, spec.scale || 28), k = W / 56;
|
| 449 |
+
const col = toRgba(a), sw = Math.max(0.3, spec.thickness || 1) / k;
|
| 450 |
+
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${f(W)}' height='${f(100 * k)}' viewBox='0 0 56 100'>` +
|
| 451 |
+
`<path d='M28 66L0 50L0 16L28 0L56 16L56 50L28 66L28 100M28 0L28 34L0 50L0 84L28 100L56 84L56 50L28 34' fill='none' stroke='${col}' stroke-width='${f(sw)}'/></svg>`;
|
| 452 |
+
return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
const BUILDERS = { noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
|
| 456 |
+
|
| 457 |
+
/**
|
| 458 |
+
* Turn parsed layers into the CSS lists for background-image, -size, -repeat,
|
| 459 |
+
* -position and -blend-mode, top layer first.
|
| 460 |
+
* rc(cssColorWord) -> {r,g,b,a} | null
|
| 461 |
+
*/
|
| 462 |
+
export function buildTexture(layers, rc) {
|
| 463 |
+
const parts = [];
|
| 464 |
+
for (const spec of layers) {
|
| 465 |
+
const builder = BUILDERS[spec.gen];
|
| 466 |
+
if (!builder) continue;
|
| 467 |
+
for (const piece of builder(spec, rc)) parts.push({ ...piece, animate: spec.animate || 0 });
|
| 468 |
+
}
|
| 469 |
+
return {
|
| 470 |
+
image: parts.map((p) => p.image).join(', '),
|
| 471 |
+
size: parts.map((p) => p.size).join(', '),
|
| 472 |
+
repeat: parts.map((p) => p.repeat).join(', '),
|
| 473 |
+
position: parts.map((p) => p.position).join(', '),
|
| 474 |
+
blend: parts.map((p) => p.blend).join(', '),
|
| 475 |
+
count: parts.length,
|
| 476 |
+
parts,
|
| 477 |
+
};
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
/* ------------------------------------------------------------------ *
|
| 481 |
+
* deterministic value noise, for aura masks and tests
|
| 482 |
+
* ------------------------------------------------------------------ */
|
| 483 |
+
|
| 484 |
+
/** small fast seeded PRNG (mulberry32) */
|
| 485 |
+
export function rng(seed) {
|
| 486 |
+
let a = (seed >>> 0) || 1;
|
| 487 |
+
return () => {
|
| 488 |
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
| 489 |
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
| 490 |
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
| 491 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 492 |
+
};
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
/**
|
| 496 |
+
* An alpha mask (Uint8Array of size*size) for the textures that make sense as one.
|
| 497 |
+
* Used by aura so particles and glow can be grainy, dotted, scanned. Tileable.
|
| 498 |
+
*/
|
| 499 |
+
export function makeMask(layer, size = 128) {
|
| 500 |
+
const out = new Uint8Array(size * size);
|
| 501 |
+
const gen = layer.gen;
|
| 502 |
+
const k = layer.intensity === undefined ? 1 : Math.min(1, layer.intensity);
|
| 503 |
+
if (gen === 'lines') {
|
| 504 |
+
const p = Math.max(2, layer.scale || 8), w = layer.thickness || 1, ang = ((layer.angle || 0) * Math.PI) / 180;
|
| 505 |
+
// normal to a line whose direction is (cos a, -sin a) in screen coordinates
|
| 506 |
+
const c = Math.sin(ang), s = Math.cos(ang);
|
| 507 |
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
| 508 |
+
const d = (((x * c + y * s) % p) + p) % p;
|
| 509 |
+
out[y * size + x] = d < w ? 255 * k : 0;
|
| 510 |
+
}
|
| 511 |
+
return out;
|
| 512 |
+
}
|
| 513 |
+
if (gen === 'dots') {
|
| 514 |
+
const S = Math.max(2, layer.scale || 12), r = (layer.radius === undefined ? 0.2 : layer.radius) * S;
|
| 515 |
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
| 516 |
+
const dx = (x % S) - S / 2, dy = (y % S) - S / 2;
|
| 517 |
+
out[y * size + x] = Math.hypot(dx, dy) < r ? 255 * k : 0;
|
| 518 |
+
}
|
| 519 |
+
return out;
|
| 520 |
+
}
|
| 521 |
+
// everything else: value-noise fbm, tiled
|
| 522 |
+
const rand = rng((layer.seed || 0) + 17);
|
| 523 |
+
const cell = Math.max(1, Math.round(layer.scale || 3));
|
| 524 |
+
const gridN = Math.max(2, Math.floor(size / cell));
|
| 525 |
+
const lat = new Float32Array(gridN * gridN);
|
| 526 |
+
for (let i = 0; i < lat.length; i++) lat[i] = rand();
|
| 527 |
+
const oct = Math.max(1, Math.min(6, layer.octaves || 3));
|
| 528 |
+
const contrast = layer.contrast === undefined ? 1.5 : layer.contrast;
|
| 529 |
+
const thr = layer.threshold === undefined ? 0.5 : layer.threshold;
|
| 530 |
+
const smooth = (t) => t * t * (3 - 2 * t);
|
| 531 |
+
const sample = (u, v, n) => {
|
| 532 |
+
const g = Math.max(2, Math.round(n));
|
| 533 |
+
const x = ((u % 1) + 1) % 1 * g, y = ((v % 1) + 1) % 1 * g;
|
| 534 |
+
const x0 = Math.floor(x), y0 = Math.floor(y), fx = smooth(x - x0), fy = smooth(y - y0);
|
| 535 |
+
const at = (ix, iy) => lat[(((iy % gridN) + gridN) % gridN) * gridN + (((ix % gridN) + gridN) % gridN)];
|
| 536 |
+
const a = at(x0, y0), b = at(x0 + 1, y0), c = at(x0, y0 + 1), d = at(x0 + 1, y0 + 1);
|
| 537 |
+
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
|
| 538 |
+
};
|
| 539 |
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
| 540 |
+
let v = 0, amp = 0.5, n = gridN, tot = 0;
|
| 541 |
+
for (let o = 0; o < oct; o++) { v += sample(x / size, y / size, n) * amp; tot += amp; amp *= 0.5; n *= 2; }
|
| 542 |
+
v /= tot;
|
| 543 |
+
const m = Math.max(0, Math.min(1, contrast * (v - thr) + 0.5));
|
| 544 |
+
out[y * size + x] = 255 * m * k;
|
| 545 |
+
}
|
| 546 |
+
return out;
|
| 547 |
+
}
|
dssl-vscode/lib/engine/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* DSSL
|
| 2 |
|
| 3 |
export function levenshtein(a, b) {
|
| 4 |
if (a === b) return 0;
|
|
@@ -34,13 +34,13 @@ export function didYouMean(word, candidates) {
|
|
| 34 |
}
|
| 35 |
|
| 36 |
/* ------------------------------------------------------------------ *
|
| 37 |
-
* v0.6.0
|
| 38 |
*
|
| 39 |
* These are engine-native properties (`@bend`, …) that compose into ONE
|
| 40 |
* transform string, so transitions and WAAPI tween them like any other
|
| 41 |
* transform. Edge-pinned members (fold plants the top edge, bend-up plants
|
| 42 |
* the bottom edge) bake their pivot into the transform with the classic
|
| 43 |
-
* translate · rotate · translate trick
|
| 44 |
* transform-origin. In keyframe mode there is no box yet, so they emit a
|
| 45 |
* transformOrigin for WAAPI instead.
|
| 46 |
* ------------------------------------------------------------------ */
|
|
@@ -50,10 +50,10 @@ const r4 = (v) => Math.round(v * 1e4) / 1e4;
|
|
| 50 |
|
| 51 |
/**
|
| 52 |
* Compose deform parts (+ optional base transform) into a transform string.
|
| 53 |
-
* d
|
| 54 |
-
* base
|
| 55 |
-
* box
|
| 56 |
-
* Returns { transform, origin }
|
| 57 |
*/
|
| 58 |
export function composeDeform(d, base, box) {
|
| 59 |
const parts = [];
|
|
@@ -71,7 +71,7 @@ export function composeDeform(d, base, box) {
|
|
| 71 |
if (h > 0) parts.push(`translateY(${r2(h / 2)}px)`, `rotateX(${d.bendUp}deg)`, `translateY(${r2(-h / 2)}px)`);
|
| 72 |
else { parts.push(`rotateX(${d.bendUp}deg)`); origin = '50% 100%'; }
|
| 73 |
}
|
| 74 |
-
if (d.stretch && d.stretch !== 1) { // squash & stretch
|
| 75 |
const inv = r4(1 / d.stretch);
|
| 76 |
parts.push(d.stretchAxis === 'vertical'
|
| 77 |
? `scaleY(${d.stretch}) scaleX(${inv})`
|
|
@@ -83,7 +83,7 @@ export function composeDeform(d, base, box) {
|
|
| 83 |
}
|
| 84 |
|
| 85 |
/**
|
| 86 |
-
* v0.6.0
|
| 87 |
* Positive deg wraps the row around an invisible ball (a dome, ends dip);
|
| 88 |
* negative deg opens it into a smile (ends rise). Returns the pivot radius
|
| 89 |
* R (px, measured from each child's center) and one rotation angle per
|
|
|
|
| 1 |
+
/* DSSL - utils.js - tiny shared helpers */
|
| 2 |
|
| 3 |
export function levenshtein(a, b) {
|
| 4 |
if (a === b) return 0;
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
/* ------------------------------------------------------------------ *
|
| 37 |
+
* v0.6.0 - the deform family (bend / bend-y / bend-up / fold / stretch)
|
| 38 |
*
|
| 39 |
* These are engine-native properties (`@bend`, …) that compose into ONE
|
| 40 |
* transform string, so transitions and WAAPI tween them like any other
|
| 41 |
* transform. Edge-pinned members (fold plants the top edge, bend-up plants
|
| 42 |
* the bottom edge) bake their pivot into the transform with the classic
|
| 43 |
+
* translate · rotate · translate trick - that way they never fight a user
|
| 44 |
* transform-origin. In keyframe mode there is no box yet, so they emit a
|
| 45 |
* transformOrigin for WAAPI instead.
|
| 46 |
* ------------------------------------------------------------------ */
|
|
|
|
| 50 |
|
| 51 |
/**
|
| 52 |
* Compose deform parts (+ optional base transform) into a transform string.
|
| 53 |
+
* d - { bend, bendY, stretch, stretchAxis, fold, bendUp } numbers, 0 = off
|
| 54 |
+
* base - an existing transform string to run after the deform parts, or ''
|
| 55 |
+
* box - { h } element height for edge-pinned pivots; null in keyframe mode
|
| 56 |
+
* Returns { transform, origin } - origin is only set in keyframe mode.
|
| 57 |
*/
|
| 58 |
export function composeDeform(d, base, box) {
|
| 59 |
const parts = [];
|
|
|
|
| 71 |
if (h > 0) parts.push(`translateY(${r2(h / 2)}px)`, `rotateX(${d.bendUp}deg)`, `translateY(${r2(-h / 2)}px)`);
|
| 72 |
else { parts.push(`rotateX(${d.bendUp}deg)`); origin = '50% 100%'; }
|
| 73 |
}
|
| 74 |
+
if (d.stretch && d.stretch !== 1) { // squash & stretch - volume stays constant
|
| 75 |
const inv = r4(1 / d.stretch);
|
| 76 |
parts.push(d.stretchAxis === 'vertical'
|
| 77 |
? `scaleY(${d.stretch}) scaleX(${inv})`
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
/**
|
| 86 |
+
* v0.6.0 - bend-around: lay n children along a circular arc.
|
| 87 |
* Positive deg wraps the row around an invisible ball (a dome, ends dip);
|
| 88 |
* negative deg opens it into a smile (ends rise). Returns the pivot radius
|
| 89 |
* R (px, measured from each child's center) and one rotation angle per
|
dssl-vscode/lib/props-docs.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
-
/* props-docs.js
|
| 2 |
* Feeds completions and hovers. Semantic vocabulary entries carry real
|
| 3 |
* explanations; passthrough CSS properties get a short reminder that they
|
| 4 |
* pass through untouched. */
|
| 5 |
|
| 6 |
export const SEMANTIC = {
|
| 7 |
-
row: 'flex row in one line: `row: gap space.4 between center` sets display:flex, flex-direction, gap, justify-content, align-items. Warning: it writes display
|
| 8 |
column: 'flex column in one line: `column: gap 2`. Same display caveat as row: the shorthand wins if it comes later in the rule.',
|
| 9 |
grid: 'grid layout in one line: `grid: 3 gap 12` = 3 equal columns with a 12px gap.',
|
| 10 |
stack: 'everything stacked and centered: `stack: center`. Flex column + center on both axes.',
|
| 11 |
-
center: 'alignment that cannot silently no-op
|
| 12 |
size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
|
| 13 |
shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
|
| 14 |
radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
|
|
@@ -29,6 +29,16 @@ export const SEMANTIC = {
|
|
| 29 |
'visually-hidden': 'hidden from eyes, visible to screen readers.',
|
| 30 |
cover: 'fills the containing block absolutely.',
|
| 31 |
use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
seen: 'state that fires ONCE when the element first scrolls into view. Reveal-on-scroll with zero JavaScript. Composes with stagger. Cannot be combined with pseudo-elements.',
|
| 33 |
};
|
| 34 |
|
|
|
|
| 1 |
+
/* props-docs.js - the DSSL vocabulary with one-line docs.
|
| 2 |
* Feeds completions and hovers. Semantic vocabulary entries carry real
|
| 3 |
* explanations; passthrough CSS properties get a short reminder that they
|
| 4 |
* pass through untouched. */
|
| 5 |
|
| 6 |
export const SEMANTIC = {
|
| 7 |
+
row: 'flex row in one line: `row: gap space.4 between center` sets display:flex, flex-direction, gap, justify-content, align-items. Warning: it writes display - put explicit display: before it, not after.',
|
| 8 |
column: 'flex column in one line: `column: gap 2`. Same display caveat as row: the shorthand wins if it comes later in the rule.',
|
| 9 |
grid: 'grid layout in one line: `grid: 3 gap 12` = 3 equal columns with a 12px gap.',
|
| 10 |
stack: 'everything stacked and centered: `stack: center`. Flex column + center on both axes.',
|
| 11 |
+
center: 'alignment that cannot silently no-op - the engine promotes the element to a grid formatting context when needed: `center: both`.',
|
| 12 |
size: 'width and height in one line: `size: 40 20` = 40 wide, 20 tall. `size: 40` = both.',
|
| 13 |
shadow: 'box shadow from the shadow token scale: `shadow: sm | md | lg | xl`. Custom: `shadow: 0 2 4 ink/10, 0 4 12 ink/8`.',
|
| 14 |
radius: 'border-radius, bare number or token: `radius: md`, `radius: full`, `radius: 14`.',
|
|
|
|
| 29 |
'visually-hidden': 'hidden from eyes, visible to screen readers.',
|
| 30 |
cover: 'fills the containing block absolutely.',
|
| 31 |
use: 'apply a behavior: `use focus-ring`. Behaviors are named rule groups declared with `behavior name { }`.',
|
| 32 |
+
texture: 'a generated texture layered ON TOP of the element\u2019s own background: `texture: grain`, `texture: wood scale 6 intensity 0.8`, `texture: grain, scanlines`. 30 presets (grain, paper, marble, wood, carbon, halftone, ...) or build your own from a generator (noise, bump, lines, dots, grid, checker, weave, carbon, waves, hex) plus settings (scale, intensity, contrast, octaves, color, color2, blend, stretch, contour, ...). Open the designer to see them all.',
|
| 33 |
+
aura: 'light and particles around an element, with NO presets: you describe it. `aura: glow #ff7a00 30 flicker 0.3, particles #fd0 -> #f60 count 60 shape flame glowing behind`. Replaces both slots. `aura: none` clears them.',
|
| 34 |
+
'aura-glow': 'just the glow slot: `aura-glow: color.accent 24 pulse 1600`. Positional colour and radius, then settings (intensity, pulse, depth, flicker, texture(...), seed, essential). `aura-glow: none` clears only the glow.',
|
| 35 |
+
'aura-particle': 'just the particles slot: `aura-particle: #7cf -> transparent 40 up size 6 life 800 speed 30 from bottom`. Settings: count, size, life, speed, dir, spread, gravity, drag, turbulence, from, shape, grow, glowing, behind, blend, texture(...), seed, essential.',
|
| 36 |
+
'aura-particles': 'the same as `aura-particle`.',
|
| 37 |
+
'glow-particle': 'the particles slot with every particle glowing (additive light): `glow-particle: #fff 30 out`.',
|
| 38 |
+
'glow-particles': 'the same as `glow-particle`.',
|
| 39 |
+
'aura-glow-particle': 'both slots at once, settings shared: `aura-glow-particle: #ff7a00 28 pulse 900 count 50 up`. A glow layer AND a glowing particles layer.',
|
| 40 |
+
'aura-glow-particles': 'the same as `aura-glow-particle`.',
|
| 41 |
+
slider: 'styles `<input type="range">` through the WebKit/Blink (`::-webkit-slider-*`) and Firefox (`::-moz-range-*`) pseudo-elements at once: `slider: track 6 color.line radius full, fill color.accent, thumb 22 color.paper, ring 3 color.accent, glow color.accent 14, ticks 10`. Add `graph 20 45 70 90 60` for a histogram slider.',
|
| 42 |
seen: 'state that fires ONCE when the element first scrolls into view. Reveal-on-scroll with zero JavaScript. Composes with stagger. Cannot be combined with pseudo-elements.',
|
| 43 |
};
|
| 44 |
|
dssl-vscode/media/designer-core.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL designer: designer-core.js
|
| 3 |
+
*
|
| 4 |
+
* The state of the Aura, Texture and Slider designers, and the two-way trip
|
| 5 |
+
* between that state and DSSL text. Pure: no DOM, no vscode. The panel
|
| 6 |
+
* (designer.js) draws controls for this state; the tests (scripts/test-designer.mjs)
|
| 7 |
+
* check that what it writes compiles and reads back to the same thing.
|
| 8 |
+
*
|
| 9 |
+
* Colours in the state are { hex: '#rrggbb', a: 0..1 }, because that is what a
|
| 10 |
+
* colour picker plus an alpha slider produces. DSSL text gets #rrggbb, or
|
| 11 |
+
* rgba(...) when it is see-through.
|
| 12 |
+
* ========================================================================= */
|
| 13 |
+
|
| 14 |
+
import { parseColor, toHex } from '../lib/engine/color.js';
|
| 15 |
+
import { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, parseTexture } from '../lib/engine/texture.js';
|
| 16 |
+
import { parseAura, GLOW_DEFAULTS, PARTICLE_DEFAULTS, SHAPES, FROM_WORDS, GLOW_KEYS, PARTICLE_KEYS } from '../lib/engine/aura.js';
|
| 17 |
+
import { parseSlider, THUMB_SHAPES } from '../lib/engine/slider.js';
|
| 18 |
+
|
| 19 |
+
export { PRESETS, PRESET_NAMES, GENERATORS, TEXTURE_KEYS, SHAPES, FROM_WORDS, THUMB_SHAPES, GLOW_KEYS, PARTICLE_KEYS, GLOW_DEFAULTS, PARTICLE_DEFAULTS };
|
| 20 |
+
|
| 21 |
+
/* ---------------- small helpers ---------------- */
|
| 22 |
+
|
| 23 |
+
const fmt = (n) => String(+Number(n).toFixed(3));
|
| 24 |
+
export const colorToDssl = (c) => (c.a >= 0.999 ? c.hex : `rgba(${parseInt(c.hex.slice(1, 3), 16)},${parseInt(c.hex.slice(3, 5), 16)},${parseInt(c.hex.slice(5, 7), 16)},${fmt(c.a)})`);
|
| 25 |
+
export const dsslToColor = (css) => {
|
| 26 |
+
const p = parseColor(css);
|
| 27 |
+
return p ? { hex: toHex(p), a: +p.a.toFixed(3) } : null;
|
| 28 |
+
};
|
| 29 |
+
const hooksFor = (warnings) => ({
|
| 30 |
+
error: (m) => warnings.push(m),
|
| 31 |
+
color: (w) => w,
|
| 32 |
+
suggest: () => null,
|
| 33 |
+
});
|
| 34 |
+
/** a range as DSSL writes it: "5" when both ends match, "4 10" (low then high) otherwise */
|
| 35 |
+
const rangeText = (r) => (r[0] === r[1] ? fmt(r[0]) : `${fmt(Math.min(...r))} ${fmt(Math.max(...r))}`);
|
| 36 |
+
|
| 37 |
+
/* ====================================================================== *
|
| 38 |
+
* AURA
|
| 39 |
+
* ====================================================================== */
|
| 40 |
+
|
| 41 |
+
export const defaultAuraState = () => ({
|
| 42 |
+
glow: { on: true, color: { hex: '#ff7a00', a: 1 }, radius: GLOW_DEFAULTS.radius, intensity: 1, pulse: 0, depth: GLOW_DEFAULTS.depth, flicker: 0, texture: '', seed: 1, essential: false },
|
| 43 |
+
particles: {
|
| 44 |
+
on: true,
|
| 45 |
+
stops: [{ hex: '#ffd24a', a: 1 }, { hex: '#ff6a00', a: 1 }, { hex: '#ff2800', a: 0 }],
|
| 46 |
+
count: 60, size: [6, 10], life: [700, 1100], speed: [40, 110], dir: 'up', deg: -90, spread: 40,
|
| 47 |
+
gravity: -40, drag: 0, turbulence: 60, from: 'top', shape: 'flame', grow: 0.3,
|
| 48 |
+
glowing: true, behind: true, blend: '', texture: '', seed: 1, essential: false,
|
| 49 |
+
},
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
function glowWords(g) {
|
| 53 |
+
const w = [colorToDssl(g.color), fmt(g.radius)];
|
| 54 |
+
if (g.intensity !== 1) w.push('intensity', fmt(g.intensity));
|
| 55 |
+
if (g.pulse) w.push('pulse', fmt(g.pulse));
|
| 56 |
+
if (g.pulse && g.depth !== GLOW_DEFAULTS.depth) w.push('depth', fmt(g.depth));
|
| 57 |
+
if (g.flicker) w.push('flicker', fmt(g.flicker));
|
| 58 |
+
if (g.texture) w.push(`texture(${g.texture})`);
|
| 59 |
+
if (g.seed !== 1) w.push('seed', fmt(g.seed));
|
| 60 |
+
if (g.essential) w.push('essential');
|
| 61 |
+
return w;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function particleWords(p, { bare = false } = {}) {
|
| 65 |
+
const D = PARTICLE_DEFAULTS;
|
| 66 |
+
const w = [p.stops.map(colorToDssl).join(' -> ')];
|
| 67 |
+
w.push('count', fmt(p.count));
|
| 68 |
+
if (p.size[0] !== D.size[0] || p.size[1] !== D.size[1]) w.push('size', rangeText(p.size));
|
| 69 |
+
if (p.life[0] !== D.life[0] || p.life[1] !== D.life[1]) w.push('life', p.life[0] === p.life[1] ? fmt(p.life[0]) : `${fmt(Math.min(...p.life))} ${fmt(Math.max(...p.life))}`);
|
| 70 |
+
if (p.speed[0] !== D.speed[0] || p.speed[1] !== D.speed[1]) w.push('speed', p.speed[0] === p.speed[1] ? fmt(p.speed[0]) : `${fmt(Math.min(...p.speed))} ${fmt(Math.max(...p.speed))}`);
|
| 71 |
+
const dir = p.dir === 'deg' ? fmt(p.deg) : p.dir;
|
| 72 |
+
if (!(p.dir === 'up')) w.push('dir', dir);
|
| 73 |
+
if (p.spread !== D.spread) w.push('spread', fmt(p.spread));
|
| 74 |
+
if (p.gravity) w.push('gravity', fmt(p.gravity));
|
| 75 |
+
if (p.drag) w.push('drag', fmt(p.drag));
|
| 76 |
+
if (p.turbulence) w.push('turbulence', fmt(p.turbulence));
|
| 77 |
+
if (p.from !== D.from) w.push('from', p.from);
|
| 78 |
+
if (p.shape !== D.shape) w.push('shape', p.shape);
|
| 79 |
+
if (p.grow !== D.grow) w.push('grow', fmt(p.grow));
|
| 80 |
+
if (p.glowing) w.push('glowing');
|
| 81 |
+
if (p.behind) w.push('behind');
|
| 82 |
+
if (p.blend) w.push('blend', p.blend);
|
| 83 |
+
if (p.texture) w.push(`texture(${p.texture})`);
|
| 84 |
+
if (p.seed !== 1) w.push('seed', fmt(p.seed));
|
| 85 |
+
if (p.essential) w.push('essential');
|
| 86 |
+
return w;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/** DSSL for an aura state. style 'separate': one declaration per slot. 'single': one aura: line. */
|
| 90 |
+
export function auraToDssl(state, style = 'separate') {
|
| 91 |
+
const g = state.glow.on ? glowWords(state.glow).join(' ') : null;
|
| 92 |
+
const p = state.particles.on ? particleWords(state.particles).join(' ') : null;
|
| 93 |
+
if (!g && !p) return 'aura: none';
|
| 94 |
+
if (style === 'single') {
|
| 95 |
+
const parts = [];
|
| 96 |
+
if (g) parts.push(`glow ${g}`);
|
| 97 |
+
if (p) parts.push(`particles ${p}`);
|
| 98 |
+
return `aura: ${parts.join(',\n ')}`;
|
| 99 |
+
}
|
| 100 |
+
const lines = [];
|
| 101 |
+
if (g) lines.push(`aura-glow: ${g}`);
|
| 102 |
+
if (p) lines.push(`aura-particle: ${p}`);
|
| 103 |
+
return lines.join('\n');
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** what a layer means when nothing is said about a setting: the LANGUAGE's defaults, not the designer's starting look */
|
| 107 |
+
const languageGlow = () => ({ on: false, color: { hex: '#ffffff', a: 1 }, radius: GLOW_DEFAULTS.radius, intensity: GLOW_DEFAULTS.intensity, pulse: 0, depth: GLOW_DEFAULTS.depth, flicker: 0, texture: '', seed: 1, essential: false });
|
| 108 |
+
const languageParticles = () => ({
|
| 109 |
+
on: false, stops: [{ hex: '#ffffff', a: 1 }, { hex: '#ffffff', a: 0 }], count: PARTICLE_DEFAULTS.count, size: [...PARTICLE_DEFAULTS.size], life: [...PARTICLE_DEFAULTS.life], speed: [...PARTICLE_DEFAULTS.speed],
|
| 110 |
+
dir: 'up', deg: -90, spread: PARTICLE_DEFAULTS.spread, gravity: 0, drag: 0, turbulence: 0, from: PARTICLE_DEFAULTS.from, shape: PARTICLE_DEFAULTS.shape, grow: PARTICLE_DEFAULTS.grow,
|
| 111 |
+
glowing: false, behind: false, blend: '', texture: '', seed: 1, essential: false,
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
/** read an aura back from DSSL text (aura:, aura-glow:, aura-particle: ...). Returns { state, notes }. */
|
| 115 |
+
export function auraFromDssl(text) {
|
| 116 |
+
const state = { glow: languageGlow(), particles: languageParticles() };
|
| 117 |
+
const notes = [];
|
| 118 |
+
const decls = splitDecls(text);
|
| 119 |
+
for (const [prop, value] of decls) {
|
| 120 |
+
if (!/^(aura|aura-glow|aura-particles?|glow-particles?|aura-glow-particles?)$/.test(prop)) continue;
|
| 121 |
+
const warnings = [];
|
| 122 |
+
const r = parseAura(prop, value, hooksFor(warnings));
|
| 123 |
+
if (!r) { notes.push(`could not read "${prop}: ${value}": ${warnings.join('; ')}`); continue; }
|
| 124 |
+
if (r.glow && r.glow[0]) {
|
| 125 |
+
const g = r.glow[0];
|
| 126 |
+
Object.assign(state.glow, { on: true });
|
| 127 |
+
if (g.color) { const c = dsslToColor(g.color); if (c) state.glow.color = c; else notes.push(`the colour "${g.color}" is a token or variable, so the designer used its default`); }
|
| 128 |
+
for (const k of ['radius', 'intensity', 'pulse', 'depth', 'flicker', 'seed']) if (g[k] !== undefined) state.glow[k] = g[k];
|
| 129 |
+
if (g.essential) state.glow.essential = true;
|
| 130 |
+
if (g.texture && g.texture[0]) state.glow.texture = textureLayerName(g.texture[0]);
|
| 131 |
+
}
|
| 132 |
+
if (r.particles && r.particles[0]) {
|
| 133 |
+
const q = r.particles[0];
|
| 134 |
+
state.particles.on = true;
|
| 135 |
+
if (q.colors) {
|
| 136 |
+
const stops = q.colors.map((c) => dsslToColor(c)).filter(Boolean);
|
| 137 |
+
if (stops.length) state.particles.stops = stops.length === 1 ? [stops[0], { ...stops[0], a: 0 }] : stops;
|
| 138 |
+
if (stops.length < q.colors.length) notes.push('a token or variable colour could not be shown on a picker and was skipped');
|
| 139 |
+
}
|
| 140 |
+
for (const k of ['count', 'spread', 'gravity', 'drag', 'turbulence', 'grow', 'seed']) if (q[k] !== undefined) state.particles[k] = q[k];
|
| 141 |
+
for (const k of ['size', 'life', 'speed']) if (q[k]) state.particles[k] = q[k];
|
| 142 |
+
if (q.dir !== undefined) { if (typeof q.dir === 'number') { state.particles.dir = q.dir === -90 ? 'up' : q.dir === 90 ? 'down' : q.dir === 180 ? 'left' : q.dir === 0 ? 'right' : 'deg'; state.particles.deg = q.dir; } else state.particles.dir = q.dir; }
|
| 143 |
+
for (const k of ['from', 'shape', 'blend']) if (q[k]) state.particles[k] = q[k];
|
| 144 |
+
for (const k of ['glowing', 'behind', 'essential']) if (q[k]) state.particles[k] = true;
|
| 145 |
+
if (q.texture && q.texture[0]) state.particles.texture = textureLayerName(q.texture[0]);
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
return { state, notes };
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* ====================================================================== *
|
| 152 |
+
* TEXTURE
|
| 153 |
+
* ====================================================================== */
|
| 154 |
+
|
| 155 |
+
export const defaultTextureLayer = (name = 'grain') => ({ name, set: {}, color: null, color2: null });
|
| 156 |
+
export const defaultTextureState = () => ({ layers: [defaultTextureLayer('wood')], base: '#b8a58a' });
|
| 157 |
+
|
| 158 |
+
const TEXTURE_NUMERIC = ['scale', 'intensity', 'contrast', 'threshold', 'octaves', 'seed', 'depth', 'light', 'angle', 'thickness', 'radius', 'softness', 'contour', 'animate'];
|
| 159 |
+
|
| 160 |
+
/** what a preset or generator would use with no overrides, to know what to leave out of the output */
|
| 161 |
+
function baselineFor(name) {
|
| 162 |
+
if (PRESETS[name]) return PRESETS[name].layers[0];
|
| 163 |
+
return {};
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export function textureLayerToDssl(layer) {
|
| 167 |
+
const w = [layer.name];
|
| 168 |
+
const base = baselineFor(layer.name);
|
| 169 |
+
for (const k of TEXTURE_NUMERIC) if (layer.set[k] !== undefined && layer.set[k] !== base[k]) w.push(k, fmt(layer.set[k]));
|
| 170 |
+
if (layer.set.stretch && (layer.set.stretch[0] !== (base.stretch || [1, 1])[0] || layer.set.stretch[1] !== (base.stretch || [1, 1])[1])) w.push('stretch', fmt(layer.set.stretch[0]), fmt(layer.set.stretch[1]));
|
| 171 |
+
if (layer.set.type && layer.set.type !== base.type) w.push('type', layer.set.type);
|
| 172 |
+
if (layer.set.blend && layer.set.blend !== base.blend) w.push('blend', layer.set.blend);
|
| 173 |
+
if (layer.set.invert) w.push('invert');
|
| 174 |
+
if (layer.color) w.push('color', colorToDssl(layer.color));
|
| 175 |
+
if (layer.color2) w.push('color2', colorToDssl(layer.color2));
|
| 176 |
+
return w.join(' ');
|
| 177 |
+
}
|
| 178 |
+
const textureLayerName = (l) => textureLayerToDssl(fromParsedTexture(l));
|
| 179 |
+
export const textureToDssl = (state) => (state.layers.length ? `texture: ${state.layers.map(textureLayerToDssl).join(', ')}` : 'texture: none');
|
| 180 |
+
|
| 181 |
+
function fromParsedTexture(parsed) {
|
| 182 |
+
const layer = defaultTextureLayer(parsed.preset || parsed.gen);
|
| 183 |
+
const base = baselineFor(layer.name);
|
| 184 |
+
for (const k of [...TEXTURE_NUMERIC, 'stretch', 'type', 'blend', 'invert']) if (parsed[k] !== undefined && JSON.stringify(parsed[k]) !== JSON.stringify(base[k])) layer.set[k] = parsed[k];
|
| 185 |
+
if (parsed.color !== undefined && parsed.color !== base.color) layer.color = dsslToColor(parsed.color);
|
| 186 |
+
if (parsed.color2 !== undefined && parsed.color2 !== base.color2) layer.color2 = dsslToColor(parsed.color2);
|
| 187 |
+
return layer;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
export function textureFromDssl(text) {
|
| 191 |
+
const state = { layers: [], base: '#b8a58a' };
|
| 192 |
+
const notes = [];
|
| 193 |
+
for (const [prop, value] of splitDecls(text)) {
|
| 194 |
+
if (prop !== 'texture') continue;
|
| 195 |
+
const warnings = [];
|
| 196 |
+
const parsed = parseTexture(value, hooksFor(warnings));
|
| 197 |
+
if (!parsed) { notes.push(`could not read "texture: ${value}": ${warnings.join('; ')}`); continue; }
|
| 198 |
+
// one parsed layer per generator layer; a multi-layer preset reads back as the preset name, once
|
| 199 |
+
const seen = new Set();
|
| 200 |
+
for (const l of parsed) {
|
| 201 |
+
const key = l.preset ? `${l.preset}` : `${l.gen}:${JSON.stringify(l)}`;
|
| 202 |
+
if (l.preset && seen.has(key)) continue;
|
| 203 |
+
seen.add(key);
|
| 204 |
+
state.layers.push(fromParsedTexture(l));
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
if (!state.layers.length) state.layers.push(defaultTextureLayer('grain'));
|
| 208 |
+
return { state, notes };
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* ====================================================================== *
|
| 212 |
+
* SLIDER
|
| 213 |
+
* ====================================================================== */
|
| 214 |
+
|
| 215 |
+
export const defaultSliderState = () => ({
|
| 216 |
+
trackH: 8, trackColor: { hex: '#445566', a: 1 }, trackRadius: 'full', trackRadiusPx: 4,
|
| 217 |
+
fill: true, fillColor: { hex: '#ff7a00', a: 1 },
|
| 218 |
+
thumb: 22, thumbColor: { hex: '#ffffff', a: 1 }, shape: 'circle',
|
| 219 |
+
ring: false, ringW: 3, ringColor: { hex: '#ff7a00', a: 1 },
|
| 220 |
+
glow: false, glowColor: { hex: '#ff7a00', a: 1 }, glowR: 14,
|
| 221 |
+
ticks: false, tickCount: 10, tickColor: { hex: '#8890a0', a: 1 }, tickLen: 6,
|
| 222 |
+
graph: false, bars: [20, 45, 70, 90, 60, 35, 50, 80, 95, 40, 60, 75], graphH: 48, graphGap: 3,
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
export function sliderToDssl(s) {
|
| 226 |
+
const c = [];
|
| 227 |
+
const radius = s.trackRadius === 'full' ? 'radius full' : `radius ${fmt(s.trackRadiusPx)}`;
|
| 228 |
+
c.push(`track ${fmt(s.trackH)} ${colorToDssl(s.trackColor)} ${radius}`);
|
| 229 |
+
c.push(s.fill ? `fill ${colorToDssl(s.fillColor)}` : 'fill none');
|
| 230 |
+
c.push(`thumb ${fmt(s.thumb)} ${colorToDssl(s.thumbColor)}`);
|
| 231 |
+
if (s.shape !== 'circle') c.push(`shape ${s.shape}`);
|
| 232 |
+
if (s.ring) c.push(`ring ${fmt(s.ringW)} ${colorToDssl(s.ringColor)}`);
|
| 233 |
+
if (s.glow) c.push(`glow ${colorToDssl(s.glowColor)} ${fmt(s.glowR)}`);
|
| 234 |
+
if (s.ticks) c.push(`ticks ${fmt(s.tickCount)} ${colorToDssl(s.tickColor)} ${fmt(s.tickLen)}`);
|
| 235 |
+
if (s.graph) { c.push(`graph ${s.bars.map(fmt).join(' ')}`); c.push(`graph-height ${fmt(s.graphH)}`); c.push(`graph-gap ${fmt(s.graphGap)}`); }
|
| 236 |
+
return `slider: ${c.join(',\n ')}`;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
export function sliderFromDssl(text) {
|
| 240 |
+
const state = defaultSliderState();
|
| 241 |
+
state.fill = false; state.ring = false; state.glow = false; state.ticks = false; state.graph = false;
|
| 242 |
+
const notes = [];
|
| 243 |
+
for (const [prop, value] of splitDecls(text)) {
|
| 244 |
+
if (prop !== 'slider') continue;
|
| 245 |
+
const warnings = [];
|
| 246 |
+
const spec = parseSlider(value, hooksFor(warnings));
|
| 247 |
+
if (!spec) { notes.push(`could not read "slider: ${value}": ${warnings.join('; ')}`); continue; }
|
| 248 |
+
const col = (css, into) => { const c = dsslToColor(css); if (c) return c; notes.push(`the colour "${css}" is a token or variable, so the designer used its default`); return into; };
|
| 249 |
+
state.trackH = spec.track.h; state.trackColor = col(spec.track.color, state.trackColor);
|
| 250 |
+
if (spec.track.radius === 'full') state.trackRadius = 'full'; else { state.trackRadius = 'px'; state.trackRadiusPx = spec.track.radius; }
|
| 251 |
+
if (spec.fill) { state.fill = true; state.fillColor = col(spec.fill, state.fillColor); }
|
| 252 |
+
state.thumb = spec.thumb.size; state.thumbColor = col(spec.thumb.color, state.thumbColor); state.shape = spec.shape;
|
| 253 |
+
if (spec.ring) { state.ring = true; state.ringW = spec.ring.w; state.ringColor = col(spec.ring.color, state.ringColor); }
|
| 254 |
+
if (spec.glow) { state.glow = true; state.glowColor = col(spec.glow.color, state.glowColor); state.glowR = spec.glow.r; }
|
| 255 |
+
if (spec.ticks) { state.ticks = true; state.tickCount = spec.ticks.n; state.tickColor = col(spec.ticks.color, state.tickColor); state.tickLen = spec.ticks.len; }
|
| 256 |
+
if (spec.graph) { state.graph = true; state.bars = spec.graph; state.graphH = spec.graphHeight; state.graphGap = spec.graphGap; }
|
| 257 |
+
}
|
| 258 |
+
return { state, notes };
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/* ---------------- shared ---------------- */
|
| 262 |
+
|
| 263 |
+
/** "prop: value" pairs from a snippet, joining continuation lines (a trailing comma continues the value) */
|
| 264 |
+
export function splitDecls(text) {
|
| 265 |
+
const out = [];
|
| 266 |
+
const lines = String(text).replace(/\/\*[\s\S]*?\*\//g, '').split('\n');
|
| 267 |
+
let cur = null;
|
| 268 |
+
for (const raw of lines) {
|
| 269 |
+
const line = raw.replace(/\/\/.*$/, '');
|
| 270 |
+
if (!line.trim()) continue;
|
| 271 |
+
if (cur && /,\s*$/.test(cur[1])) { cur[1] += ' ' + line.trim(); continue; }
|
| 272 |
+
const m = /^\s*(?:[\w.#:&>+~\[\]="' *-]+\{)?\s*([\w-]+)\s*:\s*(.*?)\s*;?\s*$/.exec(line);
|
| 273 |
+
if (m) { cur = [m[1], m[2]]; out.push(cur); } else cur = null;
|
| 274 |
+
}
|
| 275 |
+
return out.map(([p, v]) => [p, v.replace(/\s*;?\s*\}?\s*$/, '').trim()]);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/** a complete rule around the snippet, so the real compiler can check it */
|
| 279 |
+
export const wrapForCompile = (snippet, selector = '.preview') => `${selector} {\n ${snippet.replace(/\n/g, '\n ')}\n}\n`;
|
dssl-vscode/media/designer.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* The designer follows the editor's theme through VS Code's own CSS variables; the
|
| 2 |
+
fallbacks only matter when the page is opened outside the editor (tests, screenshots). */
|
| 3 |
+
:root {
|
| 4 |
+
--bg: var(--vscode-editor-background, #1e1e1e);
|
| 5 |
+
--fg: var(--vscode-editor-foreground, #d4d4d4);
|
| 6 |
+
--muted: var(--vscode-descriptionForeground, #9a9a9a);
|
| 7 |
+
--line: var(--vscode-panel-border, #3c3c3c);
|
| 8 |
+
--field: var(--vscode-input-background, #2a2a2a);
|
| 9 |
+
--accent: var(--vscode-button-background, #0e639c);
|
| 10 |
+
--accent-fg: var(--vscode-button-foreground, #fff);
|
| 11 |
+
--good: var(--vscode-testing-iconPassed, #73c991);
|
| 12 |
+
--bad: var(--vscode-errorForeground, #f48771);
|
| 13 |
+
--font: var(--vscode-font-family, system-ui, sans-serif);
|
| 14 |
+
--mono: var(--vscode-editor-font-family, ui-monospace, Menlo, Consolas, monospace);
|
| 15 |
+
}
|
| 16 |
+
* { box-sizing: border-box; }
|
| 17 |
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 13px/1.45 var(--font); }
|
| 18 |
+
header { display: flex; align-items: center; gap: 18px; padding: 10px 16px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg); z-index: 5; }
|
| 19 |
+
h1 { font-size: 15px; margin: 0; font-weight: 600; }
|
| 20 |
+
nav { display: flex; gap: 4px; }
|
| 21 |
+
button { font: inherit; color: var(--fg); background: var(--field); border: 1px solid var(--line); border-radius: 3px; padding: 5px 12px; cursor: pointer; }
|
| 22 |
+
button:hover:not(:disabled) { border-color: var(--accent); }
|
| 23 |
+
button:disabled { opacity: .45; cursor: default; }
|
| 24 |
+
button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
| 25 |
+
button.tab.on { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
| 26 |
+
button.mini { padding: 2px 8px; font-size: 12px; }
|
| 27 |
+
button.mini.on { background: var(--accent); color: var(--accent-fg); }
|
| 28 |
+
button.mini.add { border-style: dashed; }
|
| 29 |
+
.panel[hidden] { display: none; }
|
| 30 |
+
.cols { display: grid; grid-template-columns: minmax(300px, 1fr) minmax(340px, 1fr); gap: 18px; padding: 14px 16px 6px; align-items: start; }
|
| 31 |
+
@media (max-width: 760px) { .cols { grid-template-columns: 1fr; } }
|
| 32 |
+
.right { position: sticky; top: 52px; }
|
| 33 |
+
fieldset { border: 1px solid var(--line); border-radius: 4px; margin: 0 0 12px; padding: 8px 12px 10px; min-width: 0; }
|
| 34 |
+
legend { padding: 0 6px; font-weight: 600; }
|
| 35 |
+
.row { display: grid; grid-template-columns: 112px 1fr; gap: 8px; align-items: center; padding: 2px 0; }
|
| 36 |
+
.lab { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 37 |
+
.ctl { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
| 38 |
+
.ctl input[type=range] { flex: 1; min-width: 60px; accent-color: var(--accent); }
|
| 39 |
+
.ctl input[type=range].alpha { max-width: 70px; }
|
| 40 |
+
input.num { width: 64px; background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 2px 4px; font: inherit; }
|
| 41 |
+
select { background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 3px 4px; font: inherit; max-width: 100%; }
|
| 42 |
+
input[type=color] { width: 34px; height: 24px; padding: 0; border: 1px solid var(--line); background: none; }
|
| 43 |
+
.pair { display: grid; gap: 0; }
|
| 44 |
+
.stop { display: flex; gap: 6px; align-items: center; } .stop .row { flex: 1; }
|
| 45 |
+
.note, .cap { color: var(--muted); font-size: 12px; margin: 4px 0; }
|
| 46 |
+
.stage { height: 230px; border: 1px solid var(--line); border-radius: 6px; background: #0b0a12; display: grid; place-items: center; overflow: hidden; position: relative; }
|
| 47 |
+
.target { width: 130px; height: 84px; border-radius: 14px; background: #1d1b2c; color: #ccc; display: grid; place-items: center; font-size: 12px; }
|
| 48 |
+
.bgs { display: flex; gap: 6px; align-items: center; margin: 8px 0; color: var(--muted); }
|
| 49 |
+
canvas#life-curve, canvas#live-graph { width: 100%; max-width: 360px; height: auto; border: 1px solid var(--line); border-radius: 3px; background: color-mix(in srgb, var(--field) 60%, transparent); display: block; margin-bottom: 8px; }
|
| 50 |
+
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); gap: 6px; }
|
| 51 |
+
.tilehead { grid-column: 1 / -1; color: var(--muted); font-size: 12px; margin-top: 6px; }
|
| 52 |
+
.tile { padding: 4px; display: grid; gap: 3px; text-align: left; }
|
| 53 |
+
.tile .sw { display: block; height: 42px; border-radius: 3px; }
|
| 54 |
+
.tile .nm { font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
|
| 55 |
+
.layers { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; }
|
| 56 |
+
.swatch { height: 200px; border-radius: 6px; border: 1px solid var(--line); }
|
| 57 |
+
.swrow { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; color: var(--muted); }
|
| 58 |
+
.sliderstage { padding: 38px 24px; border: 1px solid var(--line); border-radius: 6px; background: #101018; }
|
| 59 |
+
.bars { display: flex; gap: 2px; align-items: flex-end; height: 70px; margin-bottom: 6px; }
|
| 60 |
+
.bars input.bar { writing-mode: vertical-lr; direction: rtl; width: 12px; height: 70px; padding: 0; accent-color: var(--accent); }
|
| 61 |
+
footer { border-top: 1px solid var(--line); padding: 10px 16px 16px; position: sticky; bottom: 0; background: var(--bg); z-index: 5; }
|
| 62 |
+
.outhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
| 63 |
+
#code { width: 100%; font: 12.5px/1.5 var(--mono); background: var(--field); color: var(--fg); border: 1px solid var(--line); border-radius: 3px; padding: 8px; resize: vertical; }
|
| 64 |
+
.status.good { color: var(--good); } .status.bad { color: var(--bad); }
|
| 65 |
+
.btns { display: flex; gap: 8px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
|
| 66 |
+
.flash { color: var(--muted); opacity: 0; transition: opacity .2s; } .flash.on { opacity: 1; }
|
dssl-vscode/media/designer.js
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =========================================================================
|
| 2 |
+
* DSSL designer: designer.js
|
| 3 |
+
*
|
| 4 |
+
* The panel behind "DSSL: Open Designer". Three tabs (Aura, Texture, Slider)
|
| 5 |
+
* with a control for every setting, a live preview drawn by the SAME code the
|
| 6 |
+
* engine uses (the aura renderer, the texture builder, the slider CSS), a few
|
| 7 |
+
* graphs, and the DSSL it writes, checked by the real compiler as you edit.
|
| 8 |
+
*
|
| 9 |
+
* It talks to the editor through four messages:
|
| 10 |
+
* panel -> editor insert { text } copy { text } requestSelection {} ready {}
|
| 11 |
+
* editor -> panel selection { text } tab { tab }
|
| 12 |
+
* Everything else happens in here, so it can be driven (and tested) in any browser.
|
| 13 |
+
* ========================================================================= */
|
| 14 |
+
|
| 15 |
+
import * as core from './designer-core.js';
|
| 16 |
+
import { AuraManager, resolveLayer, parseAura } from '../lib/engine/aura.js';
|
| 17 |
+
import { buildTexture, parseTexture, PRESETS, TEXTURE_KEYS } from '../lib/engine/texture.js';
|
| 18 |
+
import { sliderCss, parseSlider } from '../lib/engine/slider.js';
|
| 19 |
+
import { parseColor } from '../lib/engine/color.js';
|
| 20 |
+
import { parse } from '../lib/engine/parser.js';
|
| 21 |
+
import { resolve } from '../lib/engine/resolver.js';
|
| 22 |
+
|
| 23 |
+
const vscode = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : { postMessage() {}, getState: () => null, setState() {} };
|
| 24 |
+
|
| 25 |
+
/* ---------------- tiny DOM helpers ---------------- */
|
| 26 |
+
|
| 27 |
+
const h = (tag, attrs = {}, ...kids) => {
|
| 28 |
+
const el = document.createElement(tag);
|
| 29 |
+
for (const [k, v] of Object.entries(attrs || {})) {
|
| 30 |
+
if (v === undefined || v === null || v === false) continue;
|
| 31 |
+
if (k === 'class') el.className = v;
|
| 32 |
+
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
| 33 |
+
else if (k === 'text') el.textContent = v;
|
| 34 |
+
else el.setAttribute(k, v === true ? '' : v);
|
| 35 |
+
}
|
| 36 |
+
for (const kid of kids.flat()) if (kid !== null && kid !== undefined && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(String(kid)));
|
| 37 |
+
return el;
|
| 38 |
+
};
|
| 39 |
+
const $ = (sel, root = document) => root.querySelector(sel);
|
| 40 |
+
|
| 41 |
+
/** every control calls this when it changes something */
|
| 42 |
+
let onChange = () => {};
|
| 43 |
+
|
| 44 |
+
function row(label, ...controls) { return h('label', { class: 'row' }, h('span', { class: 'lab' }, label), h('span', { class: 'ctl' }, ...controls)); }
|
| 45 |
+
|
| 46 |
+
/** a labelled slider that also shows (and accepts) its number */
|
| 47 |
+
function num(label, get, set, min, max, step = 1, hint = '') {
|
| 48 |
+
const range = h('input', { type: 'range', min, max, step, value: get() });
|
| 49 |
+
const box = h('input', { type: 'number', min, max, step, value: get(), class: 'num' });
|
| 50 |
+
const sync = (v) => { range.value = v; box.value = v; };
|
| 51 |
+
range.addEventListener('input', () => { set(Number(range.value)); box.value = range.value; onChange(); });
|
| 52 |
+
box.addEventListener('input', () => { const v = Number(box.value); if (!Number.isNaN(v)) { set(v); range.value = v; onChange(); } });
|
| 53 |
+
const el = row(label, range, box);
|
| 54 |
+
if (hint) el.title = hint;
|
| 55 |
+
el.sync = () => sync(get());
|
| 56 |
+
return el;
|
| 57 |
+
}
|
| 58 |
+
/** low and high of a range, as two sliders */
|
| 59 |
+
function pair(label, get, set, min, max, step = 1, hint = '') {
|
| 60 |
+
const a = num(`${label} from`, () => get()[0], (v) => set([Math.min(v, get()[1]), Math.max(v, get()[1])]), min, max, step, hint);
|
| 61 |
+
const b = num(`${label} to`, () => get()[1], (v) => set([Math.min(get()[0], v), Math.max(get()[0], v)]), min, max, step, hint);
|
| 62 |
+
return h('div', { class: 'pair' }, a, b);
|
| 63 |
+
}
|
| 64 |
+
function color(label, get, set, alpha = true) {
|
| 65 |
+
const c = h('input', { type: 'color', value: get().hex });
|
| 66 |
+
const a = h('input', { type: 'range', min: 0, max: 1, step: 0.01, value: get().a, title: 'opacity', class: 'alpha' });
|
| 67 |
+
c.addEventListener('input', () => { set({ hex: c.value, a: Number(a.value) }); onChange(); });
|
| 68 |
+
a.addEventListener('input', () => { set({ hex: c.value, a: Number(a.value) }); onChange(); });
|
| 69 |
+
const el = row(label, c, alpha ? a : null);
|
| 70 |
+
el.sync = () => { c.value = get().hex; a.value = get().a; };
|
| 71 |
+
return el;
|
| 72 |
+
}
|
| 73 |
+
function select(label, get, set, options, hint = '') {
|
| 74 |
+
const s = h('select', {}, options.map((o) => h('option', { value: o.value ?? o, selected: (o.value ?? o) === get() }, o.label ?? o)));
|
| 75 |
+
s.addEventListener('change', () => { set(s.value); onChange(); });
|
| 76 |
+
const el = row(label, s);
|
| 77 |
+
if (hint) el.title = hint;
|
| 78 |
+
el.sync = () => { s.value = get(); };
|
| 79 |
+
return el;
|
| 80 |
+
}
|
| 81 |
+
function check(label, get, set, hint = '') {
|
| 82 |
+
const c = h('input', { type: 'checkbox', checked: get() });
|
| 83 |
+
c.addEventListener('change', () => { set(c.checked); onChange(); });
|
| 84 |
+
const el = row(label, c);
|
| 85 |
+
if (hint) el.title = hint;
|
| 86 |
+
el.sync = () => { c.checked = get(); };
|
| 87 |
+
return el;
|
| 88 |
+
}
|
| 89 |
+
const group = (title, ...kids) => h('fieldset', {}, h('legend', {}, title), ...kids);
|
| 90 |
+
|
| 91 |
+
/* ---------------- output: the code box, compile check, buttons ---------------- */
|
| 92 |
+
|
| 93 |
+
const out = { text: '' };
|
| 94 |
+
let codeBox, statusBox;
|
| 95 |
+
|
| 96 |
+
function compileCheck(snippet) {
|
| 97 |
+
try {
|
| 98 |
+
const src = core.wrapForCompile(snippet);
|
| 99 |
+
const { ast, errors: pe } = parse(src, 'designer.dssl');
|
| 100 |
+
const doc = resolve(ast, 'designer.dssl');
|
| 101 |
+
const errors = [...pe, ...doc.errors];
|
| 102 |
+
if (errors.length) return { ok: false, text: errors.map((e) => e.message + (e.hint ? ` (${e.hint})` : '')).join('; ') };
|
| 103 |
+
return { ok: true, text: doc.warnings.length ? `compiles, ${doc.warnings.length} warning(s)` : 'compiles' };
|
| 104 |
+
} catch (e) { return { ok: false, text: String(e.message || e) }; }
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function setOutput(text) {
|
| 108 |
+
out.text = text;
|
| 109 |
+
codeBox.value = text;
|
| 110 |
+
const c = compileCheck(text);
|
| 111 |
+
statusBox.textContent = c.ok ? `✓ ${c.text}` : `✗ ${c.text}`;
|
| 112 |
+
statusBox.className = 'status ' + (c.ok ? 'good' : 'bad');
|
| 113 |
+
return c;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* ====================================================================== *
|
| 117 |
+
* AURA TAB
|
| 118 |
+
* ====================================================================== */
|
| 119 |
+
|
| 120 |
+
const aura = { state: core.defaultAuraState(), style: 'separate', manager: null, history: [], bg: '#0b0a12', stage: null, target: null };
|
| 121 |
+
|
| 122 |
+
function auraLayersFromText(text) {
|
| 123 |
+
const hooks = { error() {}, color: (w) => w, suggest: () => null };
|
| 124 |
+
const glow = [], parts = [];
|
| 125 |
+
for (const [prop, value] of core.splitDecls(text)) {
|
| 126 |
+
if (!/^(aura|aura-glow|aura-particles?)$/.test(prop)) continue;
|
| 127 |
+
const r = parseAura(prop, value, hooks);
|
| 128 |
+
if (!r) continue;
|
| 129 |
+
if (r.glow) glow.push(...r.glow);
|
| 130 |
+
if (r.particles) parts.push(...r.particles);
|
| 131 |
+
}
|
| 132 |
+
const fb = { r: 255, g: 255, b: 255, a: 1 };
|
| 133 |
+
return { glow: glow.map((l) => resolveLayer(l, parseColor, fb)), parts: parts.map((l) => resolveLayer(l, parseColor, fb)) };
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function applyAuraPreview(text) {
|
| 137 |
+
if (!aura.manager) aura.manager = new AuraManager({ reducedMotion: { matches: false }, budget: 1200 });
|
| 138 |
+
const { glow, parts } = auraLayersFromText(text);
|
| 139 |
+
aura.manager.set(aura.target, glow, parts);
|
| 140 |
+
drawLifeCurves();
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function drawLifeCurves() {
|
| 144 |
+
const cv = $('#life-curve');
|
| 145 |
+
if (!cv) return;
|
| 146 |
+
const x = cv.getContext('2d');
|
| 147 |
+
const W = cv.width, H = cv.height;
|
| 148 |
+
x.clearRect(0, 0, W, H);
|
| 149 |
+
const css = getComputedStyle(document.body);
|
| 150 |
+
const fg = css.getPropertyValue('--vscode-editor-foreground') || '#ccc';
|
| 151 |
+
x.strokeStyle = 'rgba(128,128,128,.35)'; x.lineWidth = 1;
|
| 152 |
+
for (let i = 0; i <= 4; i++) { const y = 6 + (i / 4) * (H - 16); x.beginPath(); x.moveTo(26, y); x.lineTo(W - 4, y); x.stroke(); }
|
| 153 |
+
x.fillStyle = fg; x.font = '10px sans-serif';
|
| 154 |
+
x.fillText('1', 8, 12); x.fillText('0', 8, H - 8); x.fillText('life →', W - 40, H - 1);
|
| 155 |
+
const p = aura.state.particles;
|
| 156 |
+
if (!p.on) { x.fillText('particles are off', 40, H / 2); return; }
|
| 157 |
+
const stops = p.stops.length ? p.stops : [{ a: 1 }];
|
| 158 |
+
const alphaAt = (t) => { const n = stops.length - 1; if (n <= 0) return stops[0].a; const f = Math.min(0.9999, t) * n; const i = Math.floor(f); return stops[i].a + (stops[i + 1].a - stops[i].a) * (f - i); };
|
| 159 |
+
const line = (fn, strokeStyle, max) => { x.strokeStyle = strokeStyle; x.lineWidth = 2; x.beginPath(); for (let i = 0; i <= 60; i++) { const t = i / 60; const v = Math.max(0, Math.min(1, fn(t) / max)); const px = 26 + t * (W - 30), py = H - 10 - v * (H - 16); i ? x.lineTo(px, py) : x.moveTo(px, py); } x.stroke(); };
|
| 160 |
+
line(alphaAt, '#ff9f43', 1);
|
| 161 |
+
const gmax = Math.max(1, p.grow);
|
| 162 |
+
line((t) => 1 + (p.grow - 1) * t, '#48dbfb', gmax);
|
| 163 |
+
x.fillStyle = '#ff9f43'; x.fillText('opacity', 34, 14);
|
| 164 |
+
x.fillStyle = '#48dbfb'; x.fillText(`size (1 → ${p.grow})`, 90, 14);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function drawLiveGraph() {
|
| 168 |
+
const cv = $('#live-graph');
|
| 169 |
+
if (!cv || !aura.manager) return;
|
| 170 |
+
const st = aura.manager.stats;
|
| 171 |
+
aura.history.push(st.particles);
|
| 172 |
+
if (aura.history.length > 150) aura.history.shift();
|
| 173 |
+
const x = cv.getContext('2d'), W = cv.width, H = cv.height;
|
| 174 |
+
x.clearRect(0, 0, W, H);
|
| 175 |
+
const max = Math.max(10, ...aura.history);
|
| 176 |
+
x.strokeStyle = '#7bed9f'; x.lineWidth = 1.5; x.beginPath();
|
| 177 |
+
aura.history.forEach((v, i) => { const px = (i / 149) * W, py = H - 4 - (v / max) * (H - 10); i ? x.lineTo(px, py) : x.moveTo(px, py); });
|
| 178 |
+
x.stroke();
|
| 179 |
+
x.fillStyle = getComputedStyle(document.body).color; x.font = '10px sans-serif';
|
| 180 |
+
x.fillText(`${st.particles} particles · ${st.fps || 0} fps · ${st.frameMs.toFixed(2)} ms/frame`, 6, 11);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function buildAuraTab() {
|
| 184 |
+
const s = aura.state;
|
| 185 |
+
const g = s.glow, p = s.particles;
|
| 186 |
+
const controls = [];
|
| 187 |
+
const texOpts = ['', 'grain', 'noise', 'rough', 'sand', 'cloud', 'glitter', 'dots', 'lines', 'halftone', 'scanlines'].map((v) => ({ value: v, label: v || 'none' }));
|
| 188 |
+
|
| 189 |
+
controls.push(group('Output',
|
| 190 |
+
select('Write it as', () => aura.style, (v) => { aura.style = v; }, [{ value: 'separate', label: 'aura-glow and aura-particle' }, { value: 'single', label: 'one aura: line' }]),
|
| 191 |
+
));
|
| 192 |
+
const glowCtl = group('Glow',
|
| 193 |
+
check('On', () => g.on, (v) => { g.on = v; }, 'a soft light around the element'),
|
| 194 |
+
color('Colour', () => g.color, (v) => { g.color = v; }),
|
| 195 |
+
num('Radius', () => g.radius, (v) => { g.radius = v; }, 1, 200, 1, 'how far the light reaches, px'),
|
| 196 |
+
num('Intensity', () => g.intensity, (v) => { g.intensity = v; }, 0, 3, 0.05),
|
| 197 |
+
num('Pulse (ms)', () => g.pulse, (v) => { g.pulse = v; }, 0, 6000, 50, '0 is steady; otherwise one breath in and out takes this long'),
|
| 198 |
+
num('Pulse depth', () => g.depth, (v) => { g.depth = v; }, 0, 1, 0.05),
|
| 199 |
+
num('Flicker', () => g.flicker, (v) => { g.flicker = v; }, 0, 1, 0.05, 'random flicker, like a flame'),
|
| 200 |
+
select('Texture', () => g.texture, (v) => { g.texture = v; }, texOpts),
|
| 201 |
+
num('Seed', () => g.seed, (v) => { g.seed = v; }, 1, 99, 1),
|
| 202 |
+
check('Essential', () => g.essential, (v) => { g.essential = v; }, 'keep animating under reduced motion'),
|
| 203 |
+
);
|
| 204 |
+
const stopsBox = h('div', { class: 'stops' });
|
| 205 |
+
const renderStops = () => {
|
| 206 |
+
stopsBox.replaceChildren();
|
| 207 |
+
p.stops.forEach((st, i) => {
|
| 208 |
+
stopsBox.append(h('div', { class: 'stop' },
|
| 209 |
+
color(`Colour ${i + 1}`, () => p.stops[i], (v) => { p.stops[i] = v; }),
|
| 210 |
+
h('button', { class: 'mini', title: 'remove this colour', disabled: p.stops.length <= 1, onclick: () => { p.stops.splice(i, 1); renderStops(); onChange(); } }, '×')));
|
| 211 |
+
});
|
| 212 |
+
stopsBox.append(h('button', { class: 'mini add', disabled: p.stops.length >= 5, onclick: () => { p.stops.push({ ...p.stops[p.stops.length - 1], a: 0 }); renderStops(); onChange(); } }, '+ add a colour stop'));
|
| 213 |
+
};
|
| 214 |
+
renderStops();
|
| 215 |
+
const partCtl = group('Particles',
|
| 216 |
+
check('On', () => p.on, (v) => { p.on = v; }),
|
| 217 |
+
h('div', { class: 'note' }, 'Colours blend along a particle\'s life, left to right.'), stopsBox,
|
| 218 |
+
num('Count', () => p.count, (v) => { p.count = v; }, 1, 300, 1, 'how many are alive at once'),
|
| 219 |
+
pair('Size', () => p.size, (v) => { p.size = v; }, 1, 60, 1, 'diameter in px'),
|
| 220 |
+
pair('Life (ms)', () => p.life, (v) => { p.life = v; }, 100, 4000, 50),
|
| 221 |
+
pair('Speed (px/s)', () => p.speed, (v) => { p.speed = v; }, 0, 400, 5),
|
| 222 |
+
select('Direction', () => p.dir, (v) => { p.dir = v; }, ['up', 'down', 'left', 'right', 'out', 'in', { value: 'deg', label: 'custom degrees' }]),
|
| 223 |
+
num('Degrees', () => p.deg, (v) => { p.deg = v; }, -180, 180, 1, 'used when direction is custom (-90 is up)'),
|
| 224 |
+
num('Spread', () => p.spread, (v) => { p.spread = v; }, 0, 360, 1, 'width of the fan of directions'),
|
| 225 |
+
num('Gravity', () => p.gravity, (v) => { p.gravity = v; }, -300, 300, 5, 'negative pulls up, like a flame'),
|
| 226 |
+
num('Drag', () => p.drag, (v) => { p.drag = v; }, 0, 5, 0.1),
|
| 227 |
+
num('Turbulence', () => p.turbulence, (v) => { p.turbulence = v; }, 0, 300, 5),
|
| 228 |
+
select('From', () => p.from, (v) => { p.from = v; }, core.FROM_WORDS),
|
| 229 |
+
select('Shape', () => p.shape, (v) => { p.shape = v; }, core.SHAPES),
|
| 230 |
+
num('Grow', () => p.grow, (v) => { p.grow = v; }, 0, 4, 0.1, 'end size as a multiple of the start'),
|
| 231 |
+
check('Glowing', () => p.glowing, (v) => { p.glowing = v; }),
|
| 232 |
+
check('Behind the element', () => p.behind, (v) => { p.behind = v; }, 'draw only outside the element'),
|
| 233 |
+
select('Blend', () => p.blend, (v) => { p.blend = v; }, [{ value: '', label: 'automatic' }, 'add', 'normal']),
|
| 234 |
+
select('Texture', () => p.texture, (v) => { p.texture = v; }, texOpts),
|
| 235 |
+
num('Seed', () => p.seed, (v) => { p.seed = v; }, 1, 99, 1),
|
| 236 |
+
check('Essential', () => p.essential, (v) => { p.essential = v; }),
|
| 237 |
+
);
|
| 238 |
+
controls.push(glowCtl, partCtl);
|
| 239 |
+
|
| 240 |
+
const stage = h('div', { class: 'stage' }, h('div', { class: 'target', id: 'aura-target' }, 'element'));
|
| 241 |
+
aura.stage = stage; aura.target = $('.target', stage);
|
| 242 |
+
const bgPick = h('div', { class: 'bgs' }, h('span', {}, 'Preview on:'), ...[['#0b0a12', 'dark'], ['#f2f0f7', 'light'], ['#2a2f3a', 'slate']].map(([c, n]) => h('button', { class: 'mini', onclick: () => { stage.style.background = c; aura.target.style.background = c === '#f2f0f7' ? '#cfc8e0' : '#1d1b2c'; aura.target.style.color = c === '#f2f0f7' ? '#222' : '#ccc'; } }, n)));
|
| 243 |
+
const graphs = group('Graphs',
|
| 244 |
+
h('div', { class: 'cap' }, 'One particle over its life'), h('canvas', { id: 'life-curve', width: 360, height: 90 }),
|
| 245 |
+
h('div', { class: 'cap' }, 'Live: particles alive'), h('canvas', { id: 'live-graph', width: 360, height: 50 }));
|
| 246 |
+
return { controls, preview: [stage, bgPick, graphs], onChange: () => { const text = core.auraToDssl(aura.state, aura.style); setOutput(text); applyAuraPreview(text); } };
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/* ====================================================================== *
|
| 250 |
+
* TEXTURE TAB
|
| 251 |
+
* ====================================================================== */
|
| 252 |
+
|
| 253 |
+
const tex = { state: core.defaultTextureState(), sel: 0 };
|
| 254 |
+
|
| 255 |
+
function textureCss(text) {
|
| 256 |
+
const hooks = { error() {}, color: (w) => w, suggest: () => null };
|
| 257 |
+
const decls = core.splitDecls(text).filter(([p]) => p === 'texture');
|
| 258 |
+
if (!decls.length) return null;
|
| 259 |
+
const layers = parseTexture(decls[0][1], hooks);
|
| 260 |
+
return layers ? buildTexture(layers, parseColor) : null;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function buildTextureTab() {
|
| 264 |
+
const s = tex.state;
|
| 265 |
+
const layer = () => s.layers[Math.min(tex.sel, s.layers.length - 1)] || s.layers[0];
|
| 266 |
+
const grid = h('div', { class: 'tiles' });
|
| 267 |
+
const tile = (name, kind) => {
|
| 268 |
+
const b = textureCss(`texture: ${name}`);
|
| 269 |
+
const t = h('button', { class: 'tile', title: PRESETS[name] ? PRESETS[name].doc : `generator: ${name}`, onclick: () => { s.layers[tex.sel] = core.defaultTextureLayer(name); rebuild(); } }, h('span', { class: 'sw' }), h('span', { class: 'nm' }, name));
|
| 270 |
+
if (b) Object.assign($('.sw', t).style, { backgroundColor: '#b8a58a', backgroundImage: b.image, backgroundSize: b.size, backgroundRepeat: b.repeat, backgroundPosition: b.position, backgroundBlendMode: b.blend });
|
| 271 |
+
t.dataset.name = name;
|
| 272 |
+
return t;
|
| 273 |
+
};
|
| 274 |
+
core.PRESET_NAMES.forEach((n) => grid.append(tile(n)));
|
| 275 |
+
grid.append(h('div', { class: 'tilehead' }, 'Generators (start from scratch)'));
|
| 276 |
+
core.GENERATORS.forEach((n) => grid.append(tile(n)));
|
| 277 |
+
|
| 278 |
+
const layerBar = h('div', { class: 'layers' });
|
| 279 |
+
const settings = h('div', {});
|
| 280 |
+
const swatch = h('div', { class: 'swatch', id: 'tex-swatch' });
|
| 281 |
+
const baseColor = h('input', { type: 'color', value: s.base, onchange: (e) => { s.base = e.target.value; swatch.style.backgroundColor = s.base; } });
|
| 282 |
+
const numeric = ['scale', 'intensity', 'contrast', 'threshold', 'octaves', 'seed', 'depth', 'light', 'angle', 'thickness', 'radius', 'softness', 'contour', 'animate'];
|
| 283 |
+
|
| 284 |
+
function rebuild() {
|
| 285 |
+
layerBar.replaceChildren(...s.layers.map((l, i) => h('button', { class: 'mini' + (i === tex.sel ? ' on' : ''), onclick: () => { tex.sel = i; rebuild(); } }, `${i + 1}. ${l.name}`)),
|
| 286 |
+
h('button', { class: 'mini add', disabled: s.layers.length >= 4, onclick: () => { s.layers.push(core.defaultTextureLayer('grain')); tex.sel = s.layers.length - 1; rebuild(); } }, '+ stack another'),
|
| 287 |
+
h('button', { class: 'mini', disabled: s.layers.length <= 1, onclick: () => { s.layers.splice(tex.sel, 1); tex.sel = 0; rebuild(); } }, 'remove this'));
|
| 288 |
+
settings.replaceChildren();
|
| 289 |
+
const L = layer();
|
| 290 |
+
const base = PRESETS[L.name] ? PRESETS[L.name].layers[0] : {};
|
| 291 |
+
const eff = (k) => (L.set[k] !== undefined ? L.set[k] : base[k]);
|
| 292 |
+
const info = h('div', { class: 'note' }, PRESETS[L.name] ? `${L.name}: ${PRESETS[L.name].doc}` : `${L.name} (a generator: set it up yourself)`);
|
| 293 |
+
settings.append(info);
|
| 294 |
+
for (const k of numeric) {
|
| 295 |
+
const def = TEXTURE_KEYS[k];
|
| 296 |
+
const dflt = base[k] !== undefined ? base[k] : (def.min + Math.min(def.max, 20) / 2);
|
| 297 |
+
const step = def.int ? 1 : def.max <= 2 ? 0.05 : def.max <= 40 ? 0.1 : 1;
|
| 298 |
+
const el = num(k, () => (eff(k) !== undefined ? eff(k) : dflt), (v) => { L.set[k] = v; }, def.min, Math.min(def.max, k === 'scale' ? 300 : def.max), step, def.doc);
|
| 299 |
+
settings.append(el);
|
| 300 |
+
}
|
| 301 |
+
const st = eff('stretch') || [1, 1];
|
| 302 |
+
settings.append(
|
| 303 |
+
num('stretch x', () => (L.set.stretch || st)[0], (v) => { L.set.stretch = [v, (L.set.stretch || st)[1]]; }, 1, 80, 1, TEXTURE_KEYS.stretch.doc),
|
| 304 |
+
num('stretch y', () => (L.set.stretch || st)[1], (v) => { L.set.stretch = [(L.set.stretch || st)[0], v]; }, 1, 80, 1),
|
| 305 |
+
select('blend', () => eff('blend') || 'normal', (v) => { L.set.blend = v; }, ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion']),
|
| 306 |
+
select('type', () => eff('type') || 'fractal', (v) => { L.set.type = v; }, ['fractal', 'turbulence']),
|
| 307 |
+
check('invert', () => !!L.set.invert, (v) => { L.set.invert = v || undefined; }),
|
| 308 |
+
h('div', { class: 'pair' },
|
| 309 |
+
check('own colour', () => !!L.color, (v) => { L.color = v ? (L.color || { hex: '#000000', a: 1 }) : null; rebuild(); }),
|
| 310 |
+
L.color ? color('colour', () => L.color, (v) => { L.color = v; }) : null),
|
| 311 |
+
h('div', { class: 'pair' },
|
| 312 |
+
check('own colour 2', () => !!L.color2, (v) => { L.color2 = v ? (L.color2 || { hex: '#ffffff', a: 1 }) : null; rebuild(); }),
|
| 313 |
+
L.color2 ? color('colour 2', () => L.color2, (v) => { L.color2 = v; }) : null),
|
| 314 |
+
h('button', { class: 'mini', onclick: () => { L.set = {}; L.color = null; L.color2 = null; rebuild(); } }, 'reset this layer to the preset'));
|
| 315 |
+
api.onChange();
|
| 316 |
+
}
|
| 317 |
+
const api = {
|
| 318 |
+
controls: [group('Textures', h('div', { class: 'note' }, 'Pick a preset (or a generator), then adjust it. Stack up to 4.'), grid), group('Layers', layerBar, settings)],
|
| 319 |
+
preview: [h('div', { class: 'swrow' }, h('span', {}, 'Preview on:'), baseColor), swatch,
|
| 320 |
+
h('div', { class: 'sample', style: 'margin-top:8px' }, 'The same texture over text: ', h('b', {}, 'DSSL'))],
|
| 321 |
+
onChange: () => {
|
| 322 |
+
const text = core.textureToDssl(s);
|
| 323 |
+
const c = setOutput(text);
|
| 324 |
+
const b = c.ok ? textureCss(text) : null;
|
| 325 |
+
swatch.style.backgroundColor = s.base;
|
| 326 |
+
Object.assign(swatch.style, { backgroundImage: b ? b.image : 'none', backgroundSize: b ? b.size : '', backgroundRepeat: b ? b.repeat : '', backgroundPosition: b ? b.position : '', backgroundBlendMode: b ? b.blend : '' });
|
| 327 |
+
},
|
| 328 |
+
afterBuild: rebuild,
|
| 329 |
+
};
|
| 330 |
+
return api;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* ====================================================================== *
|
| 334 |
+
* SLIDER TAB
|
| 335 |
+
* ====================================================================== */
|
| 336 |
+
|
| 337 |
+
const sl = { state: core.defaultSliderState() };
|
| 338 |
+
|
| 339 |
+
function buildSliderTab() {
|
| 340 |
+
const s = sl.state;
|
| 341 |
+
const style = h('style', { id: 'pv-style' });
|
| 342 |
+
const input = h('input', { type: 'range', id: 'pv', min: 0, max: 100, value: 60 });
|
| 343 |
+
input.addEventListener('input', () => input.style.setProperty('--dssl-p', String(Number(input.value) / 100)));
|
| 344 |
+
input.style.setProperty('--dssl-p', '0.6');
|
| 345 |
+
const bars = h('div', { class: 'bars' });
|
| 346 |
+
const renderBars = () => {
|
| 347 |
+
bars.replaceChildren(...s.bars.map((v, i) => h('input', { type: 'range', class: 'bar', min: 0, max: 100, value: v, title: `bar ${i + 1}`, oninput: (e) => { s.bars[i] = Number(e.target.value); onChange(); } })));
|
| 348 |
+
};
|
| 349 |
+
renderBars();
|
| 350 |
+
const controls = [
|
| 351 |
+
group('Track',
|
| 352 |
+
num('Height', () => s.trackH, (v) => { s.trackH = v; }, 1, 40),
|
| 353 |
+
color('Colour', () => s.trackColor, (v) => { s.trackColor = v; }),
|
| 354 |
+
select('Corners', () => s.trackRadius, (v) => { s.trackRadius = v; }, [{ value: 'full', label: 'fully round' }, { value: 'px', label: 'a number of px' }]),
|
| 355 |
+
num('Corner px', () => s.trackRadiusPx, (v) => { s.trackRadiusPx = v; }, 0, 20),
|
| 356 |
+
check('Fill', () => s.fill, (v) => { s.fill = v; }, 'colour the part before the thumb'),
|
| 357 |
+
color('Fill colour', () => s.fillColor, (v) => { s.fillColor = v; })),
|
| 358 |
+
group('Thumb',
|
| 359 |
+
num('Size', () => s.thumb, (v) => { s.thumb = v; }, 6, 60),
|
| 360 |
+
color('Colour', () => s.thumbColor, (v) => { s.thumbColor = v; }),
|
| 361 |
+
select('Shape', () => s.shape, (v) => { s.shape = v; }, core.THUMB_SHAPES),
|
| 362 |
+
check('Ring', () => s.ring, (v) => { s.ring = v; }), num('Ring width', () => s.ringW, (v) => { s.ringW = v; }, 1, 10), color('Ring colour', () => s.ringColor, (v) => { s.ringColor = v; }),
|
| 363 |
+
check('Glow', () => s.glow, (v) => { s.glow = v; }), color('Glow colour', () => s.glowColor, (v) => { s.glowColor = v; }), num('Glow radius', () => s.glowR, (v) => { s.glowR = v; }, 2, 60)),
|
| 364 |
+
group('Ticks',
|
| 365 |
+
check('Ticks', () => s.ticks, (v) => { s.ticks = v; }), num('Count', () => s.tickCount, (v) => { s.tickCount = v; }, 2, 40), color('Colour', () => s.tickColor, (v) => { s.tickColor = v; }), num('Length', () => s.tickLen, (v) => { s.tickLen = v; }, 2, 20)),
|
| 366 |
+
group('Graph (histogram slider)',
|
| 367 |
+
check('Show graph', () => s.graph, (v) => { s.graph = v; }),
|
| 368 |
+
num('Height', () => s.graphH, (v) => { s.graphH = v; }, 16, 120), num('Bar gap', () => s.graphGap, (v) => { s.graphGap = v; }, 0, 10),
|
| 369 |
+
h('div', { class: 'cap' }, 'Bar heights'), bars,
|
| 370 |
+
h('div', { class: 'pair' },
|
| 371 |
+
h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, (_, i) => Math.round(50 + 45 * Math.sin(i / 1.6))); renderBars(); onChange(); } }, 'wave'),
|
| 372 |
+
h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, (_, i) => Math.round(((i + 1) / s.bars.length) * 100)); renderBars(); onChange(); } }, 'ramp'),
|
| 373 |
+
h('button', { class: 'mini', onclick: () => { s.bars = Array.from({ length: s.bars.length }, () => Math.round(10 + Math.random() * 90)); renderBars(); onChange(); } }, 'random'),
|
| 374 |
+
h('button', { class: 'mini', onclick: () => { s.bars = s.bars.length < 40 ? [...s.bars, 50] : s.bars; renderBars(); onChange(); } }, '+ bar'),
|
| 375 |
+
h('button', { class: 'mini', onclick: () => { s.bars = s.bars.length > 2 ? s.bars.slice(0, -1) : s.bars; renderBars(); onChange(); } }, '− bar'))),
|
| 376 |
+
];
|
| 377 |
+
return {
|
| 378 |
+
controls,
|
| 379 |
+
preview: [h('div', { class: 'sliderstage' }, input, style), h('div', { class: 'note' }, 'This panel is Chromium, so it shows the -webkit-slider-* rules. The -moz-range-* rules for Firefox are written alongside them by the compiler.')],
|
| 380 |
+
onChange: () => {
|
| 381 |
+
const text = core.sliderToDssl(s);
|
| 382 |
+
const c = setOutput(text);
|
| 383 |
+
const hooks = { error() {}, color: (w) => w, suggest: () => null };
|
| 384 |
+
const decl = core.splitDecls(text).find(([p]) => p === 'slider');
|
| 385 |
+
const spec = c.ok && decl ? parseSlider(decl[1], hooks) : null;
|
| 386 |
+
if (!spec) return;
|
| 387 |
+
const css = sliderCss(spec);
|
| 388 |
+
const rule = (sel, decls) => `${sel} { ${decls.map((d) => `${d.prop}: ${d.value}`).join('; ')} }`;
|
| 389 |
+
style.textContent = [rule('#pv', css.base), ...css.shadow.filter((x) => x.pseudo.startsWith('-webkit')).map((x) => rule(`#pv::${x.pseudo}`, x.decls))].join('\n');
|
| 390 |
+
},
|
| 391 |
+
};
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* ====================================================================== *
|
| 395 |
+
* the shell: tabs, output box, buttons
|
| 396 |
+
* ====================================================================== */
|
| 397 |
+
|
| 398 |
+
const tabs = {};
|
| 399 |
+
let current = 'aura';
|
| 400 |
+
|
| 401 |
+
function showTab(name) {
|
| 402 |
+
current = name;
|
| 403 |
+
document.body.dataset.tab = name;
|
| 404 |
+
for (const [n, t] of Object.entries(tabs)) { t.panel.hidden = n !== name; t.btn.classList.toggle('on', n === name); }
|
| 405 |
+
if (name !== 'aura' && aura.manager) aura.manager.set(aura.target, [], []);
|
| 406 |
+
onChange = tabs[name].api.onChange;
|
| 407 |
+
onChange();
|
| 408 |
+
vscode.setState({ tab: name, aura: aura.state, tex: tex.state, sl: sl.state, style: aura.style });
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
/** load a snippet (from the editor's selection) into whichever tab it belongs to */
|
| 412 |
+
function loadText(text) {
|
| 413 |
+
const notes = [];
|
| 414 |
+
const props = core.splitDecls(text).map(([p]) => p);
|
| 415 |
+
let target = null;
|
| 416 |
+
if (props.some((p) => /^(aura|aura-|glow-)/.test(p))) {
|
| 417 |
+
const r = core.auraFromDssl(text); Object.assign(aura.state, r.state); notes.push(...r.notes); target = 'aura';
|
| 418 |
+
} else if (props.includes('texture')) {
|
| 419 |
+
const r = core.textureFromDssl(text); Object.assign(tex.state, r.state); tex.sel = 0; notes.push(...r.notes); target = 'texture';
|
| 420 |
+
} else if (props.includes('slider')) {
|
| 421 |
+
const r = core.sliderFromDssl(text); Object.assign(sl.state, r.state); notes.push(...r.notes); target = 'slider';
|
| 422 |
+
}
|
| 423 |
+
if (!target) { flash('Nothing to load: select an aura, texture or slider declaration first.'); return; }
|
| 424 |
+
mount(target); // rebuild the controls from the new state
|
| 425 |
+
flash(notes.length ? `Loaded. ${notes.join(' ')}` : 'Loaded from your selection.');
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
let flashBox;
|
| 429 |
+
function flash(msg) { flashBox.textContent = msg; flashBox.className = 'flash on'; clearTimeout(flash.t); flash.t = setTimeout(() => { flashBox.className = 'flash'; }, 5000); }
|
| 430 |
+
|
| 431 |
+
function mount(name) {
|
| 432 |
+
const builders = { aura: buildAuraTab, texture: buildTextureTab, slider: buildSliderTab };
|
| 433 |
+
const api = builders[name]();
|
| 434 |
+
const t = tabs[name];
|
| 435 |
+
t.api = api;
|
| 436 |
+
t.panel.replaceChildren(h('div', { class: 'cols' }, h('div', { class: 'left' }, ...api.controls), h('div', { class: 'right' }, ...api.preview)));
|
| 437 |
+
if (api.afterBuild) { onChange = api.onChange; api.afterBuild(); }
|
| 438 |
+
if (current === name) { onChange = api.onChange; api.onChange(); }
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function init() {
|
| 442 |
+
const app = $('#app');
|
| 443 |
+
const saved = vscode.getState();
|
| 444 |
+
if (saved) {
|
| 445 |
+
if (saved.aura) Object.assign(aura.state, saved.aura);
|
| 446 |
+
if (saved.tex) Object.assign(tex.state, saved.tex);
|
| 447 |
+
if (saved.sl) Object.assign(sl.state, saved.sl);
|
| 448 |
+
if (saved.style) aura.style = saved.style;
|
| 449 |
+
}
|
| 450 |
+
const start = document.body.dataset.tab || (saved && saved.tab) || 'aura';
|
| 451 |
+
flashBox = h('div', { class: 'flash' });
|
| 452 |
+
codeBox = h('textarea', { id: 'code', readonly: true, rows: 7, spellcheck: false });
|
| 453 |
+
statusBox = h('div', { class: 'status' });
|
| 454 |
+
const header = h('header', {}, h('h1', {}, 'DSSL designer'), h('nav', {},
|
| 455 |
+
...[['aura', 'Aura'], ['texture', 'Texture'], ['slider', 'Slider']].map(([n, l]) => { const b = h('button', { class: 'tab', 'data-tab': n, onclick: () => showTab(n) }, l); tabs[n] = { btn: b, panel: h('section', { class: 'panel', hidden: true }) }; return b; })));
|
| 456 |
+
const outBox = h('footer', {},
|
| 457 |
+
h('div', { class: 'outhead' }, h('strong', {}, 'DSSL'), statusBox),
|
| 458 |
+
codeBox,
|
| 459 |
+
h('div', { class: 'btns' },
|
| 460 |
+
h('button', { id: 'insert', class: 'primary', onclick: () => vscode.postMessage({ type: 'insert', text: out.text }) }, 'Insert at cursor'),
|
| 461 |
+
h('button', { id: 'copy', onclick: async () => { vscode.postMessage({ type: 'copy', text: out.text }); try { await navigator.clipboard.writeText(out.text); } catch { /* the editor copies for us */ } flash('Copied.'); } }, 'Copy'),
|
| 462 |
+
h('button', { id: 'load', onclick: () => vscode.postMessage({ type: 'requestSelection' }) }, 'Load from selection'),
|
| 463 |
+
flashBox));
|
| 464 |
+
app.replaceChildren(header, ...Object.values(tabs).map((t) => t.panel), outBox);
|
| 465 |
+
for (const n of Object.keys(tabs)) mount(n);
|
| 466 |
+
showTab(start);
|
| 467 |
+
setInterval(() => { if (current === 'aura') drawLiveGraph(); }, 100);
|
| 468 |
+
window.addEventListener('message', (e) => {
|
| 469 |
+
const m = e.data || {};
|
| 470 |
+
if (m.type === 'selection') loadText(m.text || '');
|
| 471 |
+
if (m.type === 'tab' && tabs[m.tab]) showTab(m.tab);
|
| 472 |
+
});
|
| 473 |
+
vscode.postMessage({ type: 'ready' });
|
| 474 |
+
window.__designer = { core, aura, tex, sl, showTab, loadText, get out() { return out.text; } }; // for tests and curious people
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
init();
|
dssl-vscode/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
{
|
| 2 |
"name": "dssl-vscode",
|
| 3 |
"displayName": "DSSL Language Support",
|
| 4 |
-
"description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry, hover docs. Works in VS Code and VSCodium.",
|
| 5 |
-
"version": "0.
|
| 6 |
"publisher": "ehs-studio",
|
| 7 |
"icon": "icon.png",
|
| 8 |
"license": "MIT",
|
|
@@ -14,14 +14,24 @@
|
|
| 14 |
"Linters"
|
| 15 |
],
|
| 16 |
"keywords": [
|
| 17 |
-
"
|
| 18 |
"css",
|
| 19 |
-
"
|
| 20 |
"design tokens",
|
| 21 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
],
|
| 23 |
"activationEvents": [
|
| 24 |
-
"onLanguage:dssl"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
],
|
| 26 |
"main": "./extension.js",
|
| 27 |
"contributes": {
|
|
@@ -59,10 +69,61 @@
|
|
| 59 |
"description": "Milliseconds to wait after a keystroke before re-running the compiler."
|
| 60 |
}
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
},
|
| 64 |
"repository": {
|
| 65 |
"type": "git",
|
| 66 |
"url": "https://huggingface.co/EHS-Studio/dssl"
|
| 67 |
}
|
| 68 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
"name": "dssl-vscode",
|
| 3 |
"displayName": "DSSL Language Support",
|
| 4 |
+
"description": "The DSSL style language in your editor: syntax highlighting, real-compiler diagnostics (the same errors the browser console shows), completions from the live token registry and for textures, auras and sliders, hover docs, an interactive designer with sliders and live previews for aura, texture and range sliders, and a CSS to DSSL converter. Works in VS Code and VSCodium.",
|
| 5 |
+
"version": "0.9.0",
|
| 6 |
"publisher": "ehs-studio",
|
| 7 |
"icon": "icon.png",
|
| 8 |
"license": "MIT",
|
|
|
|
| 14 |
"Linters"
|
| 15 |
],
|
| 16 |
"keywords": [
|
| 17 |
+
"aura",
|
| 18 |
"css",
|
| 19 |
+
"css converter",
|
| 20 |
"design tokens",
|
| 21 |
+
"designer",
|
| 22 |
+
"dssl",
|
| 23 |
+
"particles",
|
| 24 |
+
"style language",
|
| 25 |
+
"styling",
|
| 26 |
+
"texture"
|
| 27 |
],
|
| 28 |
"activationEvents": [
|
| 29 |
+
"onLanguage:dssl",
|
| 30 |
+
"onCommand:dssl.openDesigner",
|
| 31 |
+
"onCommand:dssl.openAuraDesigner",
|
| 32 |
+
"onCommand:dssl.openTextureDesigner",
|
| 33 |
+
"onCommand:dssl.openSliderDesigner",
|
| 34 |
+
"onCommand:dssl.convertCss"
|
| 35 |
],
|
| 36 |
"main": "./extension.js",
|
| 37 |
"contributes": {
|
|
|
|
| 69 |
"description": "Milliseconds to wait after a keystroke before re-running the compiler."
|
| 70 |
}
|
| 71 |
}
|
| 72 |
+
},
|
| 73 |
+
"commands": [
|
| 74 |
+
{
|
| 75 |
+
"command": "dssl.openDesigner",
|
| 76 |
+
"title": "Open the designer",
|
| 77 |
+
"category": "DSSL",
|
| 78 |
+
"icon": "$(paintcan)"
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"command": "dssl.openAuraDesigner",
|
| 82 |
+
"title": "Design an aura (glow and particles)",
|
| 83 |
+
"category": "DSSL"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"command": "dssl.openTextureDesigner",
|
| 87 |
+
"title": "Design a texture",
|
| 88 |
+
"category": "DSSL"
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"command": "dssl.openSliderDesigner",
|
| 92 |
+
"title": "Design a range slider",
|
| 93 |
+
"category": "DSSL"
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"command": "dssl.convertCss",
|
| 97 |
+
"title": "Convert a CSS file to DSSL",
|
| 98 |
+
"category": "DSSL"
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
+
"menus": {
|
| 102 |
+
"editor/title": [
|
| 103 |
+
{
|
| 104 |
+
"command": "dssl.openDesigner",
|
| 105 |
+
"when": "resourceLangId == dssl",
|
| 106 |
+
"group": "navigation"
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"editor/context": [
|
| 110 |
+
{
|
| 111 |
+
"command": "dssl.openDesigner",
|
| 112 |
+
"when": "resourceLangId == dssl",
|
| 113 |
+
"group": "navigation@9"
|
| 114 |
+
}
|
| 115 |
+
],
|
| 116 |
+
"explorer/context": [
|
| 117 |
+
{
|
| 118 |
+
"command": "dssl.convertCss",
|
| 119 |
+
"when": "resourceExtname == .css",
|
| 120 |
+
"group": "navigation@9"
|
| 121 |
+
}
|
| 122 |
+
]
|
| 123 |
}
|
| 124 |
},
|
| 125 |
"repository": {
|
| 126 |
"type": "git",
|
| 127 |
"url": "https://huggingface.co/EHS-Studio/dssl"
|
| 128 |
}
|
| 129 |
+
}
|