feat: text editor and blog post

This commit is contained in:
nobody 2025-11-01 12:08:03 -07:00
commit 78297efe5c
Signed by: GrocerPublishAgent
GPG key ID: D460CD54A9E3AB86
17 changed files with 2008 additions and 24 deletions

View file

@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:5000

1
frontend/.env.production Normal file
View file

@ -0,0 +1 @@
VITE_API_BASE_URL=/p/salience-editor/api

View file

@ -6,23 +6,28 @@
"": {
"name": "my-qwik-empty-starter",
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"mathjs": "^15.0.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-schema-basic": "^1.2.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.4",
"prosemirror-view": "^1.41.3"
"prosemirror-view": "^1.41.3",
"temml": "^0.11.11"
},
"devDependencies": {
"@builder.io/qwik": "^1.17.1",
"@builder.io/qwik-city": "^1.17.1",
"@eslint/js": "latest",
"@tailwindcss/vite": "^4.1.16",
"@types/node": "20.19.0",
"eslint": "9.32.0",
"eslint-plugin-qwik": "^1.17.1",
"globals": "16.4.0",
"prettier": "3.6.2",
"tailwindcss": "^4.1.16",
"typescript": "5.4.5",
"typescript-eslint": "8.38.0",
"typescript-plugin-css-modules": "latest",
@ -1203,6 +1208,56 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
@ -1940,6 +1995,303 @@
"win32"
]
},
"node_modules/@tailwindcss/node": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.16"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz",
"integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"tailwindcss": "4.1.16"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@ -3325,7 +3677,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@ -3637,6 +3988,20 @@
"node": ">= 0.4"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -4574,8 +4939,7 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/graphemer": {
"version": "1.4.0",
@ -5351,6 +5715,16 @@
"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
"license": "MIT"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@ -5487,6 +5861,277 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@ -5538,6 +6183,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
@ -6997,6 +7652,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
@ -7942,6 +8608,35 @@
"url": "https://opencollective.com/svgo"
}
},
"node_modules/tailwindcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/temml": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/temml/-/temml-0.11.11.tgz",
"integrity": "sha512-Z/Ihgwad+ges0ez6+KmKWZ3o4BYbP6aZ/cU94cVtN+DwxwqxjHgcF4Z6cb9jLkKN+aU7uni165HsIxLHs5/TqA==",
"license": "MIT",
"engines": {
"node": ">=18.13.0"
}
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
@ -8580,7 +9275,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/valibot": {

View file

@ -26,11 +26,13 @@
"@builder.io/qwik": "^1.17.1",
"@builder.io/qwik-city": "^1.17.1",
"@eslint/js": "latest",
"@tailwindcss/vite": "^4.1.16",
"@types/node": "20.19.0",
"eslint": "9.32.0",
"eslint-plugin-qwik": "^1.17.1",
"globals": "16.4.0",
"prettier": "3.6.2",
"tailwindcss": "^4.1.16",
"typescript": "5.4.5",
"typescript-eslint": "8.38.0",
"typescript-plugin-css-modules": "latest",
@ -39,12 +41,15 @@
"vite-tsconfig-paths": "^4.2.1"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"mathjs": "^15.0.0",
"prosemirror-commands": "^1.7.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-schema-basic": "^1.2.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.4",
"prosemirror-view": "^1.41.3"
"prosemirror-view": "^1.41.3",
"temml": "^0.11.11"
}
}

View file

@ -0,0 +1,91 @@
Social reality might not be a video game, but theres no point trying to imagine that. Crass realism obscures the rules. Besides, society converges upon a video game or immersive ludic simulation even if it isnt one already. Such gamification is a trend to note. It has multiple drivers.
As games get more convincing, they increasingly set the default perceptual frame. In technologically-advanced societies, game-like systems are becoming the obvious model for self-understanding. The reception for stories with this slant continuously improves. Even scientific theorizing is drawn to them. The topic might seem less than serious, even definitively so, but ultimately it isnt. Alternatively, it might be said that there is a non-seriousness more serious than seriousness itself. Everything will be gamified.
In the epoch of WMD deterrence, unlimited warfare is not allowed to happen. Instead, it is perpetually simulated. Every serious military establishment becomes a set of war-games in process. From the peak of virtual thermonuclear spasm, war-gaming cascades down through the apparatus of conventional war-fighting capability, and then spreads outwards like a blast-wave through every civilian forum of institutional planning. Eventually (but already) to have been war-gamed just means to have been thought through. A war-game is less serious than a war, but its the most serious way to process things when war is off the table. Its also from its inception the way to keep war off the table. Si vis pacem, para bellum, which means playing it out.
That everything would be gamified was decided during the pre-history of computing, at the latest. The potential to simulate anything, which is only to say emerging artificial intelligence, leaves nothing that cannot be folded into a game, given time.
In their take-off phase, at least, machines demand strict rules, responding well only to precise instructions. They dissipate fog or, more precisely, motivate its dissipation. The world adjusts to machine intelligence by sharpening its definition. Formalization acquires precise practical criteria.
Anything that trains an AI has to function as a game. This is because playing games is the only thing AI can ever do. For synthetic intelligence to be applied to a problem, of any kind, it has to be gamified. Then strategies can be pursued, in strict compliance with rules, to maximize success. Optimization games are the only kind that exist, and inversely.
While games are made, or adopted, for AI to play in, games incorporate AIs into themselves, as components. Simply making games that work requires computer game companies to nurture a semi-independent machine intelligence lineage of their own. Playing against AIs, and also alongside them, is ever increasingly what gamers do. This is what the single player option abbreviates, most obviously. The antisocial path stimulates nonlinearity on the side of the machine. Machine intelligence escalation twists into an ever tighter loop, continually intensifying, as it plays games against itself, and against anyone else who wanders in to challenge it.
The games that are relentlessly improving the kind gamers play are competition for society. They provide an alternative to traditional modes of social involvement. Japanese otaku pioneered these paths of departure. Wherever technology crests, the world follows them. Advance tends to exit.
Incel or involuntary celibate is in some ways a misleading term for what is happening here. The condition of fundamental social alienation described is no more involuntary than any other opt-out. The incel gamer no longer finds the most basic of all traditional social relations worth it. There are better games. The revealed preference is evident regardless of what might be said. They grasp games as a way to leave.
At the same time, the PUAs or pick-up artists have been pulling everything apart from the opposite direction. If they have a bible it is Neil Strausss The Game. Rather than abandoning mating for games, the PUAs gamify mating.
Turning it into a game is the first step to becoming good at it. In the same way, war is the game of princes. Everything is a game to those who are good at it, and as a condition of them coming to be good at it. This is the serious non-seriousness previously touched upon. Excellence has ludic foundations. Play or be played, as it is cynically said.
How could it not become ever more obvious that Gamergate had to happen? If non-Wokeness in the gaming industry had never been an issue, it would be a sign that nothing of importance was taking place there. In reality, it could not be left alone because it was destined to eat everything. The topic was seriously non-serious, as the GameStop short-squeeze was more recently.
Good or well-constructed games have a number of characteristic features.
Firstly, they can only be played by the rules. Cheating is forbidden less than it is made impossible. Physics is like this. It proscribes nothing that can be done (as Crowley notoriously noticed). Rules that can be broken are a failure of game design. The more impractical it is to cheat, the better the game.
Secondly, they have an implicit meta-rule that strictly prohibits changing the rules. To change the rules is to invent a new game, which cannot be done during play. Different games, with different rules, coexist simultaneously, rather than replacing each other successively.
Thirdly, rule sets permit outcomes, without ever dictating them. Rules and strategies are mutually independent. Strategies compete within the rules, rather than over them. Strategic modification of rules, or the adaptation of rules to strategy, is essentially corrupt.
Fourthly, each is fully enveloped by some consistent incentive structure. This renders success and failure unambiguous, grading performance. The players always know how it went.
The games favored by game theorists, such as variants of the prisoners dilemma, compose a small subset of such well-constructed games. They cannot be transcended by cheating. Game modification is never a permitted move. They permit no legislative power. Each has a single reward dimension.
The breadth of application suggests these constraints are not difficult to meet. It might even seem that any alternative to a well-constructed game is anomalous in its degeneracy.
To be a progressive is to be in favor of changing the rules. There is one arc of history and it is made of reforms. Old rules and structures of oppression are considered broadly identical.
A conservative is against changing the rules. If they are changed, they stay changed, because changing them back would still count as change. Thus the much derided function of conservatism as anchor for the progressive ratchet.
A reactionary holds that the rules should never have been changed. Reaction would delight in restoring old rules, were it ever in a position to do so. It never is, and will never be.
A neoreactionary accepts experimental variation in rules only when rule sets are multiplied. New rules are to be tolerated only alongside, in addition to, and as a concurrent alternative to old rules. They are legitimated only by hard forks. Anything else is progress, which is in all cases misfortune.
Progress is reform without schism. While wrapping itself in the mantle of science, it incarnates a drastic violation of scientific method. Positive or negative characterizations of progressive experiments are equally misleading. Progressive change is not experimental, but rather something closer to the opposite. It substitutes for testing, and disdains controls. Synchronic comparison is deliberately suppressed, and the more thorough the suppression the more progressive it is. Multiplication without difference is bad, but difference without multiplication is worse.
In a corrupt society, or bad social game, the ruling class makes rules. There is nothing natural about this, regardless of what we are told. It is only in the wake of a radical socio-cultural calamity that it happens.
In any well-constructed game, winning is entirely distinct from re-writing the rules. For instance, a speculative investor however successful does not modify the functioning of the stock market, any more than a chess master takes advantage of each victory to change the way pieces move.
Capitalism, as a game, works well when businesses follow economic rules they have no role in formulating. Even in the political sphere, comparatively stable constitutional principles and norms are expected to conserve themselves resiliently through vicissitudes of party conflict. This point might confidently be strengthened. Invulnerability of political rules-of-the-game to party fortune is regime stability. The contrary condition, in which party dominance overwhelms political rules and permits the dictation of new ones, defines revolution. Competition within rules is politics, but competition to set rules is war. When politics seems more like war than it used to, this is why.
The common law tradition permits no legislation. Laws are discovered, never made. The notion of law-making is abominable, and inconsistent with the existence of a free people. According to the only truly English position, legislation is always and essentially tyranny.
Optimally, the rule of law is a pleonasm. It means only that the rules rule. Nothing could be more inevitable.
Algorithmic governance says roughly the same. Yet under conditions of fundamental social corruption the rule of law appears closer to an oxymoron. Is it not always men who in fact rule, with rules as their instrument? If so, formal procedure is mostly mystique. Yet this question is itself an index of decadence. Only when a game is already broken does it appear so lacking in authoritative constraint.
America is a game so badly broken the world is positively awe-struck by it. Its hegemony ensures that everyone has to care. Most of the planet finds itself sucked into a game whose formal rule set is a chaotic cancerous mess.
When America had a frontier, it was a land of real experiments. New games of all kinds were explored, in parallel. The national heritage of schismatic religion meant different rules applied in different places. From the mid- to late-Nineteenth Century, hardening of the Union and the closing of the frontier brought religious, moral, and political consolidation. American experiments entered their twilight, and The American Experiment was celebrated, integrally, which was no experiment at all, but only progress.
Never change the rules is an example of a good meta-rule. What, then, exemplifies a bad one? We should all be playing the same game is probably the very worst. At least, nothing more sinister can easily be conceived.
We dont like the same games. More particularly, we dont all like the kind of domination game that requires everyone to play the same game, even if some like it a lot. The game industry has an abundance of practical evidence on ludic preference diversity, far exceeding what is required to make the basic point. We want to play different games is the basic point. Despite its overwhelming obviousness, getting it installed as a default is surprisingly difficult. In part, this is Social Domination game-play at work.
There are people who dislike chess. There are many more who dont like it enough to play it continuously, and exclusively. Chess, nevertheless, is a well-constructed game. No one is disgraced by their dedication to it.
Social Domination is a contender for the worst-constructed game in history. “Lets keep changing the rules until everybody likes it,” it suggests tacitly. It simultaneously makes other suggestions which directly contradict this, but never to the point of ensuring its retraction. As if this were not already bad enough, it also mandates universal cheating. Its rules are so numerous, unstable, and poorly-formulated that they are both theoretically and practically unintelligible. The latitude with which rule-violations are to be avoided or penalized has become a strategic consideration. Players in weak positions have to scrupulously avoid gross rule-violations and are increasingly terrorized by trivial, absurd, and informal norms. Players in strong positions get to ignore any rules they dont like.
The best Social Domination players get to decide whether to permit opt outs from Social Domination. The incentive effects here are entirely predictable. However much you hate the game, you have to win it to escape. Those who like it are far more likely to do well at it. On the rare occasions when those who dont like it do well, they suddenly find they like it more than they had thought, or have invested too much in it to quit. To escape it means fighting it, which means playing it, which means investing in it. Getting out involves putting people into a position from which they can get you out, and that position turns out to be a lot more comfortable than either getting out, or letting anyone else out. These dynamics are clear to everyone.
As it all becomes ever more obvious, cynicism explodes. No one is any longer really fooled by the thinly-stretched, saccharine, hysterical idealism. Its all power and who-whom, as the practitioners of Cultural Revolution are the first to admit. “Were fucking you, and we get to call it good, because were winning, and youre not.” Thats the whole of it. For anyone who thinks Social Domination is a great game to play, it makes more sense than it ever has. There are many such people. Theyre not going away.
“Is it time yet?”
“Its a bit later actually.”
“Its a bit later than now? Or nows a bit later than it?”
Its time to war-game getting the hell out, and away from them. The technological platforms for it are almost in place. Begin to use them, and theyll arrive faster. Its all been set up in a way that cant be stopped. The games industry is the template.
Any exit ramp that looks serious is fake. Social Domination manages serious threats easily, making them actually non-serious. Such challenges fall under its rules, dialectically, and merely make it bigger. Theres no way to seriously oppose it without playing into it.
Any real exit has to be seriously non-serious. Game it out. Play another, different game on the side, shifting everything steadily to the side. Migrate intelligence-capital onto a million ludic frontiers, where exit hatches. No one will take it seriously until its too late.
Its getting ever easier to try things out inside games. Any kind of plotting that doesnt take this route will soon seem obsolete.
The means of simulation do not need to be seized, but they do need to be proliferated. Other frontiers will open, but none so soon.

View file

@ -0,0 +1,284 @@
import { component$, useComputed$, $, type Signal } from "@builder.io/qwik";
import {
calculateRawScores,
autoSolveParameters,
type SalienceParameters,
} from "~/utils/autosolver";
type DebugRow = {
index: number;
rawScore: number;
exponentialSpread: number;
shiftedDown: number;
clamped: number;
}
interface DebugPanelProps {
isOpen: Signal<boolean>;
parameters: SalienceParameters;
adjacencyMatrix: Signal<number[][] | null>;
}
export const DebugPanel = component$<DebugPanelProps>(
({ isOpen, parameters, adjacencyMatrix }) => {
// Calculate debug rows from adjacency matrix whenever it or parameters change
const debugRows = useComputed$(() => {
if (!adjacencyMatrix.value) return [];
const rawScores = calculateRawScores(adjacencyMatrix.value, parameters.randomWalkLength);
const rows: DebugRow[] = rawScores.map((rawScore, i) => {
// Step 1: Exponential spread
const exponentialSpread = rawScore ** parameters.exponent;
// Step 2: Shift down
const shiftedDown = exponentialSpread - parameters.threshold;
// Step 3: Clamp to [0, 1]
const clamped = Math.max(0, Math.min(1, shiftedDown));
return {
index: i,
rawScore,
exponentialSpread,
shiftedDown,
clamped,
};
});
// Sort by exponentialSpread column (descending)
return [...rows].sort((a, b) => b.exponentialSpread - a.exponentialSpread);
});
// Calculate statistics from debug rows
const statistics = useComputed$(() => {
if (debugRows.value.length === 0) return null;
const rawScores = debugRows.value.map((r) => r.rawScore);
const spreadScores = debugRows.value.map((r) => r.exponentialSpread);
const shiftedScores = debugRows.value.map((r) => r.shiftedDown);
const clampedScores = debugRows.value.map((r) => r.clamped);
const rawMin = Math.min(...rawScores);
const rawMax = Math.max(...rawScores);
const rawRange = rawMax - rawMin;
const spreadMin = Math.min(...spreadScores);
const spreadMax = Math.max(...spreadScores);
const spreadRange = spreadMax - spreadMin;
const shiftedMin = Math.min(...shiftedScores);
const shiftedMax = Math.max(...shiftedScores);
const shiftedRange = shiftedMax - shiftedMin;
const clampedMin = Math.min(...clampedScores);
const clampedMax = Math.max(...clampedScores);
const clampedRange = clampedMax - clampedMin;
const zeroCount = clampedScores.filter((s) => s === 0).length;
return {
rawMin,
rawMax,
rawRange,
spreadMin,
spreadMax,
spreadRange,
shiftedMin,
shiftedMax,
shiftedRange,
clampedMin,
clampedMax,
clampedRange,
zeroCount,
total: clampedScores.length,
};
});
// Auto-solve handler
const handleAutoSolve = $(() => {
if (!adjacencyMatrix.value) return;
const solved = autoSolveParameters(adjacencyMatrix.value, parameters);
if (solved.exponent !== undefined) {
parameters.exponent = solved.exponent;
}
if (solved.threshold !== undefined) {
parameters.threshold = solved.threshold;
}
});
return (
<>
{/* Debug panel toggle button */}
<button
class="fixed top-4 right-4 w-10 h-10 rounded-full border border-gray-300 bg-white cursor-pointer text-lg flex items-center justify-center shadow-md hover:bg-gray-50 hover:shadow-lg transition-all z-[1000]"
onClick$={() => (isOpen.value = !isOpen.value)}
title={isOpen.value ? "Close debug panel" : "Open debug panel"}
>
{isOpen.value ? "✕" : "🐛"}
</button>
{/* Debug panel */}
{isOpen.value && (
<div class="fixed top-0 right-0 bottom-0 w-[500px] bg-white border-l border-gray-300 shadow-lg flex flex-col z-[999]">
<h2 class="m-0 px-5 py-4 text-lg border-b border-gray-200 bg-gray-50">
Debug: Salience Score Breakdown
</h2>
{/* Controls */}
<div class="px-5 py-3 border-b border-gray-200 bg-gray-50">
<div class="flex gap-4 items-center text-xs mb-2">
<div class="flex items-center gap-2">
<label class="text-gray-700 font-semibold">Exponent:</label>
<input
type="number"
value={parameters.exponent}
onInput$={(e) => {
parameters.exponent =
parseFloat((e.target as HTMLInputElement).value) || 1;
}}
class="w-16 px-2 py-1 border border-gray-300 rounded"
step="0.1"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-gray-700 font-semibold">Threshold:</label>
<input
type="number"
value={parameters.threshold}
onInput$={(e) => {
parameters.threshold =
parseFloat((e.target as HTMLInputElement).value) || 0;
}}
class="w-16 px-2 py-1 border border-gray-300 rounded"
step="0.01"
/>
</div>
<div class="flex items-center gap-2">
<label class="text-gray-700 font-semibold">Walk Length:</label>
<input
type="number"
value={parameters.randomWalkLength}
onInput$={(e) => {
parameters.randomWalkLength =
parseInt((e.target as HTMLInputElement).value) || 1;
}}
class="w-16 px-2 py-1 border border-gray-300 rounded"
step="1"
/>
</div>
</div>
<button
onClick$={handleAutoSolve}
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
>
Run solver
</button>
</div>
{/* Statistics section */}
{statistics.value && (
<div class="px-5 py-2.5 bg-blue-50 border-b border-blue-200 text-xs">
<div class="font-semibold mb-1.5 text-blue-900">
Statistics
</div>
<div class="space-y-1 font-mono text-[11px]">
<div class="flex justify-between">
<span class="text-gray-600">Raw Score:</span>
<span class="font-semibold">
{statistics.value.rawMin.toFixed(3)} -{" "}
{statistics.value.rawMax.toFixed(3)} (Δ{" "}
{statistics.value.rawRange.toFixed(3)})
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">
1) Exponential spread (x^{parameters.exponent}):
</span>
<span class="font-semibold">
{statistics.value.spreadMin.toFixed(3)} -{" "}
{statistics.value.spreadMax.toFixed(3)} (Δ{" "}
{statistics.value.spreadRange.toFixed(3)})
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">
2) Shift down (- {parameters.threshold}):
</span>
<span class="font-semibold">
{statistics.value.shiftedMin.toFixed(3)} -{" "}
{statistics.value.shiftedMax.toFixed(3)} (Δ{" "}
{statistics.value.shiftedRange.toFixed(3)})
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">
3) Clamp to [0, 1]:
</span>
<span class="font-semibold">
{statistics.value.clampedMin.toFixed(3)} -{" "}
{statistics.value.clampedMax.toFixed(3)} (Δ{" "}
{statistics.value.clampedRange.toFixed(3)})
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600"> Zero count:</span>
<span class="font-semibold">
{statistics.value.zeroCount} / {statistics.value.total} (
{((statistics.value.zeroCount / statistics.value.total) * 100).toFixed(1)}
%)
</span>
</div>
</div>
</div>
)}
<div class="flex-1 overflow-y-auto">
<table class="w-full border-collapse text-xs">
<thead class="sticky top-0 bg-gray-100 z-10">
<tr>
<th class="px-3 py-2.5 text-left font-semibold border-b-2 border-gray-300 text-gray-700">
Index
</th>
<th class="px-3 py-2.5 text-left font-semibold border-b-2 border-gray-300 text-gray-700">
x
</th>
<th class="px-3 py-2.5 text-left font-semibold border-b-2 border-gray-300 text-gray-700">
x<sup>{parameters.exponent}</sup>
</th>
<th class="px-3 py-2.5 text-left font-semibold border-b-2 border-gray-300 text-gray-700">
x<sup>{parameters.exponent}</sup> - {parameters.threshold}
</th>
<th class="px-3 py-2.5 text-left font-semibold border-b-2 border-gray-300 text-gray-700">
Final
</th>
</tr>
</thead>
<tbody>
{debugRows.value.map((row) => (
<tr key={row.index} class="hover:bg-gray-50">
<td class="px-3 py-2 border-b border-gray-100 font-mono">
{row.index}
</td>
<td class="px-3 py-2 border-b border-gray-100 font-mono">
{row.rawScore.toFixed(6)}
</td>
<td class="px-3 py-2 border-b border-gray-100 font-mono">
{row.exponentialSpread.toFixed(6)}
</td>
<td class="px-3 py-2 border-b border-gray-100 font-mono">
{row.shiftedDown.toFixed(6)}
</td>
<td class="px-3 py-2 border-b border-gray-100 font-mono">
{row.clamped.toFixed(6)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
);
}
);

View file

@ -0,0 +1,20 @@
import { component$ } from "@builder.io/qwik";
import temml from "temml";
interface MathProps {
tex: string;
display?: boolean;
}
export const Math = component$<MathProps>(({ tex, display = false }) => {
const mathml = temml.renderToString(tex, {
displayMode: display,
});
return (
<span
class={display ? "math-display" : "math-inline"}
dangerouslySetInnerHTML={mathml}
/>
);
});

View file

@ -0,0 +1,108 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "temml/dist/Temml-Local.css";
body {
margin: 0;
font-family: sans-serif;
background-color: #f5f5ee;
}
.container {
max-width: 700px;
margin: 0 auto;
padding: 15px;
}
h1 {
font-size: 24px;
margin: 15px 0 0 0;
color: #000;
}
h1 .subtitle {
display: block;
font-size: 0.7em;
font-weight: normal;
color: #a0a0a0;
}
h1 .about-link {
display: block;
font-size: 0.6em;
font-weight: normal;
color: #1565c0;
text-decoration: none;
margin-top: 8px;
}
h1 .about-link:hover {
text-decoration: underline;
}
.controls {
margin: 15px 0;
display: flex;
align-items: center;
gap: 10px;
}
.controls label {
color: #4d4d4d;
}
.controls select {
padding: 5px 10px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
background-color: white;
cursor: pointer;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
margin-left: auto;
white-space: nowrap;
}
.status-badge.clean {
background-color: #e8f5e9;
color: #2e7d32;
}
.status-badge.dirty {
background-color: #ffebee;
color: #c62828;
}
.status-badge.loading {
background-color: #e3f2fd;
color: #1565c0;
}
.editor {
color: #4d4d4d;
font-size: 15px;
line-height: 1.33em;
padding: 2em;
outline: none;
background-color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
}
.editor .sentence {
--salience: 1;
background-color: rgba(249, 239, 104, var(--salience));
}
.ProseMirror {
outline: none;
white-space: pre-wrap;
white-space: break-spaces;
word-wrap: break-word;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
}
.ProseMirror p {
margin: 0;
}
/* Math component styling */
.math-display {
margin: 1.5rem 0;
overflow-x: auto;
}
.math-inline {
display: inline-block;
vertical-align: middle;
}

View file

@ -0,0 +1,164 @@
---
title: How Salience Works
---
import { Math } from "~/components/math/math"
# How Salience Works
Salience highlights important sentences by treating your document as a graph where sentences that talk about similar things are connected. We then figure out which sentences are most "central" to the document's themes.
## Step 1: Break Text into Sentences
We use NLTK's Punkt tokenizer to split text into sentences. This handles tricky cases where simple punctuation splitting fails:
*"Dr. Smith earned his Ph.D. in 1995."* ← This is **one** sentence, not three!
## Step 2: Convert Sentences to Embeddings
Now we have <Math tex="N" /> sentences. We convert each one into a high-dimensional vector that captures its meaning:
<Math display tex="\mathbf{E} = \text{model.encode}(\text{sentences}) \in \mathbb{R}^{N \times D}" />
This gives us an **embeddings matrix** <Math tex="\mathbf{E}" /> where each row is one sentence:
<Math display tex="\mathbf{E} = \begin{bmatrix} a_1 & a_2 & a_3 & \cdots & a_D \\ b_1 & b_2 & b_3 & \cdots & b_D \\ c_1 & c_2 & c_3 & \cdots & c_D \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ z_1 & z_2 & z_3 & \cdots & z_D \end{bmatrix}" />
Where:
- <Math tex="N" /> = number of sentences (rows)
- <Math tex="D" /> = embedding dimension (768 for all-mpnet-base-v2, 1024 for gte-large-en-v1.5)
- Each row represents one sentence in semantic space
## Step 3: Build the Adjacency Matrix
Now we create a new matrix <Math tex="\mathbf{A}" /> that measures how similar each pair of sentences is. For every pair of sentences <Math tex="i" /> and <Math tex="j" />, we compute:
<Math display tex="A_{ij} = \frac{\mathbf{e}_i \cdot \mathbf{e}_j}{\|\mathbf{e}_i\| \|\mathbf{e}_j\|}" />
This is the **cosine similarity** between their embedding vectors. It tells us:
- <Math tex="A_{ij} = 1" /> means sentences are identical in meaning
- <Math tex="A_{ij} = 0" /> means sentences are unrelated
- <Math tex="A_{ij} = -1" /> means sentences are opposite in meaning
The result is an <Math tex="N \times N" /> **adjacency matrix** where <Math tex="A_{ij}" /> represents how strongly sentence <Math tex="i" /> is connected to sentence <Math tex="j" />.
## Step 4: Clean Up the Graph
We make two adjustments to the adjacency matrix to get a cleaner graph:
1. **Remove self-loops:** Set diagonal to zero (<Math tex="A_{ii} = 0" />)
- A sentence shouldn't vote for its own importance
2. **Remove negative edges:** Set <Math tex="A_{ij} = \max(0, A_{ij})" />
- Sentences with opposite meanings get disconnected
**Important assumption:** This assumes your document has a coherent main idea and that sentences are generally on-topic. We're betting that the topic with the most "semantic mass" is the *correct* topic.
**Where this breaks down:**
- **Dialectical essays** that deliberately contrast opposing viewpoints
- **Documents heavy with quotes** that argue against something
- **Debate transcripts** where both sides are equally important
- **Critical analysis** that spends significant time explaining a position before refuting it
For example: "Nuclear power is dangerous. Critics say it causes meltdowns. However, modern reactors are actually very safe."
The algorithm might highlight the criticism because multiple sentences cluster around "danger", even though the document's actual position is pro-nuclear. There's nothing inherent in the math that identifies authorial intent vs. quoted opposition.
**Bottom line:** This technique works well for coherent, single-perspective documents. It can fail when multiple competing viewpoints have similar semantic weight.
## Step 5: Normalize the Adjacency Matrix
The idea from **TextRank** is to treat similarity as a graph problem: simulate random walks and see where you're likely to end up. Sentences you frequently visit are important.
But first, we need to compute the **degree matrix** <Math tex="\mathbf{D}" />. This tells us how "connected" each sentence is:
<Math display tex="\mathbf{D} = \text{diag}(\mathbf{A} \mathbf{1})" />
Here's what this means:
- <Math tex="\mathbf{A} \mathbf{1}" /> means "sum up each row of <Math tex="\mathbf{A}" />"
- For sentence <Math tex="i" />, this gives us <Math tex="d_i = \sum_j A_{ij}" /> (the total similarity to all other sentences)
- <Math tex="\text{diag}(...)" /> puts these sums on the diagonal of a matrix
The result is a diagonal matrix that looks like:
<Math display tex="\mathbf{D} = \begin{bmatrix} d_1 & 0 & 0 & \cdots & 0 \\ 0 & d_2 & 0 & \cdots & 0 \\ 0 & 0 & d_3 & \cdots & 0 \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & 0 & \cdots & d_N \end{bmatrix}" />
**Intuition:** A sentence with high degree (<Math tex="d_i" /> is large) is connected to many other sentences or has strong connections. A sentence with low degree is more isolated.
Now we use <Math tex="\mathbf{D}" /> to normalize <Math tex="\mathbf{A}" />. There are two approaches:
Traditional normalization <Math tex="\mathbf{D}^{-1} \mathbf{A}" />:
- This creates a row-stochastic matrix (rows sum to 1)
- Interpretation: "If I'm at sentence <Math tex="i" />, what's the probability of jumping to sentence <Math tex="j" />?"
- This is like a proper Markov chain transition matrix
- Used in standard PageRank and TextRank
Spectral normalization <Math tex="\mathbf{D}^{-1/2} \mathbf{A} \mathbf{D}^{-1/2}" />:
- Used in spectral clustering and graph analysis
- Symmetry preservation: if A is symmetric (which cosine similarity matrix is), then the normalized version
stays symmetric
- The eigenvalues are bounded in [-1, 1]
- More uniform influence from all neighbors
- Better numerical properties for exponentiation
The traditional <Math tex="\mathbf{D}^{-1} \mathbf{A}" /> approach introduces potential node bias and lacks symmetry. Spectral normalization
provides a more balanced representation by symmetrizing the adjacency matrix and ensuring more uniform
neighbor influence. This method prevents high-degree nodes from dominating the graph's structure, creating a
more equitable information propagation mechanism.
With traditional normalization, sentences with many connections get their influence diluted. A sentence connected to 10 others splits its "voting power" into 10 pieces. A sentence connected to 2 others splits its power into just 2 pieces. This creates a bias against well-connected sentences.
Spectral normalization treats the graph as **undirected**, which matches how
semantic similarity works. Well-connected sentences keep their influence
proportional to connectivity. Two sentences that are similar to each other
should have equal influence on each other, not asymmetric transition
probabilities.
## Step 6: Random Walk Simulation
We simulate importance propagation by raising the normalized matrix to a power:
<Math display tex="\mathbf{s} = \mathbf{1}^T \tilde{\mathbf{A}}^k" />
Where:
- <Math tex="\mathbf{1}" /> = vector of ones (start with equal weight on all sentences)
- <Math tex="k" /> = random walk length (default: 5)
- <Math tex="\mathbf{s}" /> = raw salience scores for each sentence
**Intuition:** After <Math tex="k" /> steps of random walking through the similarity graph, which sentences have we visited most? Those are the central, important sentences.
## Step 7: Map Scores to Highlight Colors
Now we have a vector of raw salience scores from the random walk. Problem: these scores have no physical meaning. Different embedding models produce wildly different ranges:
- Model A on Doc 1: `[0.461, 1.231]`
- Model B on Doc 2: `[0.892, 1.059]`
We need to turn this vector of arbitrary numbers into CSS highlight opacities in `[0, 1]`. Here's the reasoning behind creating the remapping function:
I could do trivial linear scaling - multiply by a constant to get scores into some range like <Math tex="X" /> to <Math tex="X + 2" />. But let's try to make the top sentences stand out more. One trick: exponentiation. Since human perception of brightness is not linear, exponentiation will preserve order but push the top values apart more. It makes the top few sentences really pop out.
**Building the remapping function**
Given a salience vector <Math tex="\mathbf{s}" /> with values ranging from <Math tex="\min(\mathbf{s})" /> to <Math tex="\max(\mathbf{s})" />:
1. **Find an exponent** <Math tex="p" /> such that <Math tex="\max(\mathbf{s}^p) \approx \min(\mathbf{s}^p) + 2" />
Sure, it takes more work to find the right exponent for our target spread of 2, but that's still easy with a simple solver.
2. **Find a threshold** <Math tex="\tau" /> such that 50% of the sentences get clamped to zero.
Since I'm using this for editing documents, I only want to see highlights on roughly half the sentences—the important half.
The final opacity mapping is:
<Math display tex="\text{opacity}_i = \text{clamp}\left(s_i^p - \tau, 0, 1\right)" />
For each document, I use a simple 1D solver to find <Math tex="p" /> and <Math tex="\tau" /> that satisfy these constraints.
**Final thought:** This last step—converting the output from TextRank into highlight colors—is the weakest part of the system. I have no idea if it's actually correct or whether it even allows meaningful comparison between different embedding models. It works well enough for the intended purpose (quickly seeing which sentences to keep when editing), but the numerical values themselves are essentially arbitrary.
---
[← Back to App](/)

View file

@ -0,0 +1,11 @@
import { component$, Slot } from "@builder.io/qwik";
export default component$(() => {
return (
<div class="container mx-auto px-4 py-8">
<article class="prose prose-slate max-w-none">
<Slot />
</article>
</div>
);
});

View file

@ -1,25 +1,468 @@
import { component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
import {
component$,
useSignal,
useVisibleTask$,
useComputed$,
$,
noSerialize,
useStore,
type Signal,
type NoSerialize,
} from "@builder.io/qwik";
import { type DocumentHead, routeLoader$ } from "@builder.io/qwik-city";
import { EditorState, Plugin, Transaction } from "prosemirror-state";
import { EditorView, Decoration, DecorationSet } from "prosemirror-view";
import { Schema, DOMParser, Node as PMNode } from "prosemirror-model";
import { schema as basicSchema } from "prosemirror-schema-basic";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";
import * as math from "mathjs";
import exampleDoc from "~/assets/example-doc-1.txt?raw";
import { DebugPanel } from "~/components/debug-panel/debug-panel";
import { autoSolveParameters } from "~/utils/autosolver";
import "~/global.css";
// API Configuration
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:5000';
// Types
interface SalienceData {
source: string;
intervals: [number, number][];
adjacency: number[][];
}
interface SentenceDecoration {
from: number;
to: number;
salience: number;
}
interface SalienceParameters {
exponent: number;
threshold: number;
randomWalkLength: number;
}
type SyncState = "clean" | "dirty" | "loading";
// Create schema with marks
const salienceSchema = new Schema({
nodes: basicSchema.spec.nodes,
marks: basicSchema.spec.marks,
});
// Create decorations from sentence decorations
function createDecorations(
doc: PMNode,
decorations: SentenceDecoration[]
): DecorationSet {
const decos = decorations.map((deco) => {
return Decoration.inline(deco.from, deco.to, {
class: "sentence",
style: `--salience: ${deco.salience}`,
});
});
return DecorationSet.create(doc, decos);
}
// Plugin to manage salience decorations
function saliencePlugin(
initialDecorations: SentenceDecoration[]
): Plugin<DecorationSet> {
return new Plugin<DecorationSet>({
state: {
init(_, { doc }) {
return createDecorations(doc, initialDecorations);
},
apply(tr, old) {
// Get updated decorations from transaction metadata
const newDecorations = tr.getMeta("updateDecorations");
if (newDecorations) {
return createDecorations(tr.doc, newDecorations);
}
// Map existing decorations through the transaction
return old.map(tr.mapping, tr.doc);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
}
// Translate decorations through a transaction
function translateDecorations(
decorations: SentenceDecoration[],
tr: Transaction
): SentenceDecoration[] {
return decorations
.map((deco) => {
const from = tr.mapping.map(deco.from);
const to = tr.mapping.map(deco.to);
// Only keep decorations that still have valid ranges
if (from < to) {
return { ...deco, from, to };
}
return null;
})
.filter((deco): deco is SentenceDecoration => deco !== null);
}
// Load initial document
export const useInitialDocument = routeLoader$(() => {
return exampleDoc;
});
export default component$(() => {
const initialDocument = useInitialDocument();
const editorRef = useSignal<HTMLDivElement>();
const modelSelectRef = useSignal<HTMLSelectElement>();
const models = useSignal<string[]>([]);
const currentModel = useSignal("all-mpnet-base-v2");
const syncState = useSignal<SyncState>("clean");
const editorView = useSignal<EditorView | null>(null);
const sentenceDecorations = useSignal<SentenceDecoration[]>([]);
const salienceData = useSignal<SalienceData | null>(null);
const debounceTimer = useSignal<number | null>(null);
const pendingRequest = useSignal<AbortController | null>(null);
const debugPanelOpen = useSignal(false);
const adjacencyMatrix = useSignal<number[][] | null>(null);
const autoSolveEnabled = useSignal(true);
const parameters = useStore<SalienceParameters>({
exponent: 3,
threshold: 0.95,
randomWalkLength: 5,
});
// Auto-solve parameters when adjacency matrix changes (if enabled)
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => adjacencyMatrix.value);
track(() => autoSolveEnabled.value);
if (!adjacencyMatrix.value || !autoSolveEnabled.value) {
return;
}
const solved = autoSolveParameters(adjacencyMatrix.value, parameters);
if (solved.exponent !== undefined) {
parameters.exponent = solved.exponent;
}
if (solved.threshold !== undefined) {
parameters.threshold = solved.threshold;
}
});
// Compute salience scores when adjacency matrix or parameters change
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => adjacencyMatrix.value);
track(() => parameters.exponent);
track(() => parameters.threshold);
track(() => parameters.randomWalkLength);
if (!adjacencyMatrix.value || sentenceDecorations.value.length === 0) {
return;
}
// Compute scores using random walk
const initial = adjacencyMatrix.value.map(() => 1);
const scores = math.multiply(
initial,
math.pow(adjacencyMatrix.value, parameters.randomWalkLength) as number[][]
) as number[];
// Update salience values in existing decorations
sentenceDecorations.value = sentenceDecorations.value.map((deco, i) => {
const exponentialSpread = scores[i] ** parameters.exponent;
const shiftedDown = exponentialSpread - parameters.threshold;
const clamped = Math.max(0, Math.min(1, shiftedDown));
return {
...deco,
salience: clamped,
};
});
});
// Update editor when decorations change
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => sentenceDecorations.value);
if (!editorView.value || sentenceDecorations.value.length === 0) {
return;
}
const updateTr = editorView.value.state.tr.setMeta(
"updateDecorations",
sentenceDecorations.value
);
editorView.value.dispatch(updateTr);
});
// Load available models
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
try {
const res = await fetch(`${API_BASE_URL}/models`);
const data = await res.json();
models.value = data;
} catch (err) {
console.error("Failed to load models:", err);
}
});
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(({ track }) => {
track(() => editorRef.value);
if (!editorRef.value) return;
// Parse the initial document text into separate paragraphs
// Split by single newlines and preserve blank lines as empty paragraphs
const paragraphs = initialDocument.value
.split('\n')
.map(line => {
// Create paragraph with text if line has content, otherwise empty paragraph
const content = line.length > 0 ? [salienceSchema.text(line)] : [];
return salienceSchema.node("paragraph", null, content);
});
const initialDoc = salienceSchema.node("doc", null, paragraphs);
const state = EditorState.create({
schema: salienceSchema,
doc: initialDoc,
plugins: [
saliencePlugin([]),
keymap(baseKeymap),
],
});
const view = new EditorView(editorRef.value, {
state,
dispatchTransaction: (tr) => {
const newState = view.state.apply(tr);
view.updateState(newState);
// If this was a user edit (not a programmatic update)
if (tr.docChanged && !tr.getMeta("fromApi")) {
// Translate existing decorations to new positions
const translated = translateDecorations(sentenceDecorations.value, tr);
sentenceDecorations.value = translated;
// The reactive task will update the editor with translated decorations
// Mark as dirty and schedule API request
syncState.value = "dirty";
scheduleApiRequest(
view,
currentModel.value,
syncState,
sentenceDecorations,
salienceData,
debounceTimer,
pendingRequest,
adjacencyMatrix,
);
}
},
});
editorView.value = view;
fetchSalienceData(
currentModel.value,
view,
syncState,
sentenceDecorations,
salienceData,
pendingRequest,
adjacencyMatrix
);
// Cleanup
return () => {
view.destroy();
};
});
// Handle model change
const handleModelChange = $((event: Event) => {
const target = event.target as HTMLSelectElement;
currentModel.value = target.value;
if (editorView.value) {
fetchSalienceData(
target.value,
editorView.value,
syncState,
sentenceDecorations,
salienceData,
pendingRequest,
adjacencyMatrix,
);
}
});
return (
<>
<h1>Hi 👋</h1>
<div>
Can't wait to see what you build with qwik!
<br />
Happy coding.
<div class="container">
<h1>
Salience
<span class="subtitle">
sentence highlights based on their significance to the document
</span>
<a href="/about" class="about-link">How it works </a>
</h1>
<div class="controls">
<label for="model-select">Model:</label>
<select
id="model-select"
ref={modelSelectRef}
onChange$={handleModelChange}
>
{models.value.length === 0 ? (
<option>Loading...</option>
) : (
models.value.map((model) => (
<option
key={model}
value={model}
selected={model === currentModel.value}
>
{model}
</option>
))
)}
</select>
<label class="auto-solve-label">
<input
type="checkbox"
checked={autoSolveEnabled.value}
onChange$={(e) => {
autoSolveEnabled.value = (e.target as HTMLInputElement).checked;
}}
/>
Auto highlight colors
</label>
<span class={`status-badge ${syncState.value}`}>
{syncState.value === "clean"
? "Synchronized"
: syncState.value === "dirty"
? "Modified"
: "Processing..."}
</span>
</div>
<div
ref={editorRef}
class="editor"
/>
</div>
<DebugPanel
isOpen={debugPanelOpen}
parameters={parameters}
adjacencyMatrix={adjacencyMatrix}
/>
</>
);
});
// Shared function to fetch salience data from API
async function fetchSalienceData(
model: string,
view: EditorView,
syncState: Signal<SyncState>,
sentenceDecorations: Signal<SentenceDecoration[]>,
salienceData: Signal<SalienceData | null>,
pendingRequest: Signal<AbortController | null>,
adjacencyMatrix: Signal<number[][] | null>,
) {
// Cancel any pending request
if (pendingRequest.value) {
pendingRequest.value.abort();
}
const controller = new AbortController();
pendingRequest.value = controller;
syncState.value = "loading";
// Get current document text
const text = view.state.doc.textContent;
try {
const res = await fetch(`${API_BASE_URL}/salience?model=${encodeURIComponent(model)}`, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: text,
signal: controller.signal,
});
const data: SalienceData = await res.json();
salienceData.value = data;
adjacencyMatrix.value = data.adjacency;
// Set decorations with positions and zero salience
// The reactive task will compute and update the salience values
sentenceDecorations.value = data.intervals.map(([start, end]) => ({
from: start,
to: end,
salience: 0,
}));
syncState.value = "clean";
pendingRequest.value = null;
} catch (err: any) {
if (err.name !== "AbortError") {
console.error("Failed to load salience:", err);
syncState.value = "dirty";
}
pendingRequest.value = null;
}
}
// Schedule API request with debouncing
function scheduleApiRequest(
view: EditorView,
model: string,
syncState: Signal<SyncState>,
sentenceDecorations: Signal<SentenceDecoration[]>,
salienceData: Signal<SalienceData | null>,
debounceTimer: Signal<number | null>,
pendingRequest: Signal<AbortController | null>,
adjacencyMatrix: Signal<number[][] | null>,
) {
// Clear existing timer
if (debounceTimer.value !== null) {
clearTimeout(debounceTimer.value);
}
// Set new timer
debounceTimer.value = window.setTimeout(() => {
// Check if we're still dirty (not loading)
if (syncState.value === "dirty") {
fetchSalienceData(
model,
view,
syncState,
sentenceDecorations,
salienceData,
pendingRequest,
adjacencyMatrix
);
}
}, 1000); // 1 second debounce
}
export const head: DocumentHead = {
title: "Welcome to Qwik",
title: "Salience",
meta: [
{
name: "description",
content: "Qwik site description",
content: "Sentence highlights based on their significance to the document",
},
],
};

View file

@ -0,0 +1,102 @@
import * as math from "mathjs";
export interface SalienceParameters {
exponent: number;
threshold: number;
randomWalkLength: number;
}
// Calculate raw scores from adjacency matrix
// Why: Random walk simulates importance flow through the document graph
// Longer walks capture more global structure
export function calculateRawScores(adjacencyMatrix: number[][], randomWalkLength: number): number[] {
const initial = adjacencyMatrix.map(() => 1);
return math.multiply(
initial,
math.pow(adjacencyMatrix, randomWalkLength) as number[][]
) as number[];
}
// Solve for exponent that spreads the range to target value
// Why: We need controlled spread - too narrow and we can't distinguish sentences,
// too wide and precision is lost in the subsequent clamping step
export function solveExponentialSpread(rawScores: number[], targetRange: number = 2): number {
const rawMin = Math.min(...rawScores);
const rawMax = Math.max(...rawScores);
const calculateRange = (exp: number) => {
const spreadMin = Math.pow(rawMin, exp);
const spreadMax = Math.pow(rawMax, exp);
return spreadMax - spreadMin;
};
let exponent = 1.0;
let stepSize = 0.5;
let lastError: number | null = null;
const tolerance = 0.01;
const maxIterations = 100;
for (let i = 0; i < maxIterations; i++) {
const range = calculateRange(exponent);
const error = range - targetRange;
const absError = Math.abs(error);
// Stop when close enough - no point wasting cycles on perfection
if (absError < tolerance) {
break;
}
// We overshot the target, so cut step size in half to avoid oscillating
if (lastError !== null && Math.sign(error) !== Math.sign(lastError)) {
stepSize *= 0.5;
} else {
// Far away? Take bigger steps to converge faster
// Close? Smaller steps to avoid overshooting
if (absError > 2.0) {
stepSize *= 1.5;
} else if (absError < 0.5) {
stepSize *= 0.9;
}
}
// Small range needs bigger exponent, large range needs smaller
const direction = error < 0 ? 1 : -1;
exponent += direction * stepSize;
// Keep exponent sane - min 0.1 avoids division-like behavior,
// max 100 handles even very narrow input ranges
exponent = Math.max(0.1, Math.min(100, exponent));
lastError = error;
}
return Math.round(exponent * 10) / 10;
}
// Solve for threshold that zeros out the bottom half
// Why: We want to highlight only the top sentences, not everything
// 50% zeros gives us clear signal without losing too much information
export function solveShiftDown(spreadScores: number[], targetZeroPercent: number = 0.5): number {
// Just find the percentile value - simple and works
const sorted = [...spreadScores].sort((a, b) => a - b);
const medianThreshold = sorted[Math.floor(sorted.length * targetZeroPercent)];
return Math.round(medianThreshold * 100) / 100;
}
// Auto-solve parameters from adjacency matrix
export function autoSolveParameters(
adjacencyMatrix: number[][],
currentParameters: SalienceParameters
): Partial<SalienceParameters> {
const rawScores = calculateRawScores(adjacencyMatrix, currentParameters.randomWalkLength);
// First spread the range to ~2 so we have room to work with
const exponent = solveExponentialSpread(rawScores, 2);
// Then find where to cut off the bottom 50%
const spreadScores = rawScores.map(s => s ** exponent);
const threshold = solveShiftDown(spreadScores, 0.5);
return { exponent, threshold };
}

View file

@ -6,6 +6,7 @@ import { defineConfig, type UserConfig } from "vite";
import { qwikVite } from "@builder.io/qwik/optimizer";
import { qwikCity } from "@builder.io/qwik-city/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import tailwindcss from '@tailwindcss/vite'
import pkg from "./package.json";
type PkgDep = Record<string, string>;
@ -21,7 +22,8 @@ errorOnDuplicatesPkgDeps(devDependencies, dependencies);
*/
export default defineConfig(({ command, mode }): UserConfig => {
return {
plugins: [qwikCity(), qwikVite(), tsconfigPaths({ root: "." })],
base: mode === 'production' ? '/p/salience-editor/' : '/',
plugins: [qwikCity(), qwikVite(), tsconfigPaths({ root: "." }), tailwindcss()],
// This tells Vite which dependencies to pre-build in dev mode.
optimizeDeps: {
// Put problematic deps that break bundling here, mostly those with binaries.