recreate project
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.worktrees
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
# ---> Node
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM oven/bun:1.3.11-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json index.html tsconfig.json ./
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:1.29.4-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 mattuy
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 文字马赛克
|
||||||
|
|
||||||
|
将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 使用浏览器原生中文分词自动挑选保留词
|
||||||
|
- 支持手动切换每个候选词的显影状态
|
||||||
|
- 标点和未保留文本会被完全替换
|
||||||
|
- 混合低笔画少用汉字、故障符号、异文字母和编码残片
|
||||||
|
- 总体长度接近原文,同时允许局部片段随机伸缩
|
||||||
|
- 同一组参数可反复生成不同结果并复制为纯文本
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[MIT](./LICENSE)
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="将中文文本侵蚀成保留少量关键词的纯文本乱码艺术。"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#e9e7dd"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#121310"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<title>文字马赛克</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
absolute_redirect off;
|
||||||
|
port_in_redirect off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:css|js)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "text-mosaic",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "text-mosaic",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "text-mosaic",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --hot scripts/dev.ts",
|
||||||
|
"build": "bun scripts/build.ts",
|
||||||
|
"test": "bun test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { rm } from 'node:fs/promises'
|
||||||
|
import { resolve, sep } from 'node:path'
|
||||||
|
|
||||||
|
const projectRoot = resolve(import.meta.dir, '..')
|
||||||
|
const outputDirectory = resolve(projectRoot, 'dist')
|
||||||
|
|
||||||
|
if (!outputDirectory.startsWith(`${projectRoot}${sep}`)) {
|
||||||
|
throw new Error(`拒绝清理项目之外的输出目录:${outputDirectory}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true })
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [resolve(projectRoot, 'index.html')],
|
||||||
|
outdir: outputDirectory,
|
||||||
|
target: 'browser',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
for (const log of result.logs) {
|
||||||
|
console.error(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`已生成 ${result.outputs.length} 个文件到 dist/`)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import homepage from '../index.html'
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: Number(Bun.env.PORT ?? 5173),
|
||||||
|
routes: {
|
||||||
|
'/': homepage,
|
||||||
|
},
|
||||||
|
development: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`文字马赛克开发服务器:http://localhost:${server.port}`)
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css'
|
||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
import './style.css'
|
||||||
|
import {
|
||||||
|
generateMosaic,
|
||||||
|
selectKeywords,
|
||||||
|
type KeywordCandidate,
|
||||||
|
} from './mosaic'
|
||||||
|
|
||||||
|
const SAMPLE_TEXT =
|
||||||
|
'我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。'
|
||||||
|
|
||||||
|
const app = document.querySelector<HTMLDivElement>('#app')
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('找不到应用挂载节点 #app')
|
||||||
|
}
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-shell">
|
||||||
|
<header class="hero">
|
||||||
|
<div class="hero__index" aria-hidden="true">TEXT / EROSION / 01</div>
|
||||||
|
<div class="hero__content">
|
||||||
|
<h1>文字马赛克</h1>
|
||||||
|
<p>让文字损坏,只留下几处可辨认的痕迹。</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero__mark" aria-hidden="true">※</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="workspace" aria-label="文字马赛克生成器">
|
||||||
|
<article class="panel panel--input">
|
||||||
|
<div class="panel__heading">
|
||||||
|
<div>
|
||||||
|
<span class="panel__number">01</span>
|
||||||
|
<h2>原始文本</h2>
|
||||||
|
</div>
|
||||||
|
<span id="input-count" class="counter">0 字</span>
|
||||||
|
</div>
|
||||||
|
<label class="sr-only" for="source-text">输入原始文本</label>
|
||||||
|
<textarea
|
||||||
|
id="source-text"
|
||||||
|
maxlength="3000"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="在这里粘贴一段中文……"
|
||||||
|
></textarea>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel panel--output">
|
||||||
|
<div class="panel__heading panel__heading--dark">
|
||||||
|
<div>
|
||||||
|
<span class="panel__number">02</span>
|
||||||
|
<h2>损坏结果</h2>
|
||||||
|
</div>
|
||||||
|
<span id="output-ratio" class="counter">—</span>
|
||||||
|
</div>
|
||||||
|
<div id="mosaic-output" class="mosaic-output" role="status"></div>
|
||||||
|
<div class="output-actions">
|
||||||
|
<button id="reroll-button" class="button button--light" type="button">
|
||||||
|
换一版
|
||||||
|
</button>
|
||||||
|
<button id="copy-button" class="button button--outline" type="button">
|
||||||
|
复制文本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="control-deck" aria-label="生成参数">
|
||||||
|
<div class="control-deck__title">
|
||||||
|
<span>CONTROL SIGNAL</span>
|
||||||
|
<h2>侵蚀参数</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<label class="control" for="retention-control">
|
||||||
|
<span class="control__heading">
|
||||||
|
<span>显影</span>
|
||||||
|
<output id="retention-value">15%</output>
|
||||||
|
</span>
|
||||||
|
<span class="control__hint">留下多少原文词语</span>
|
||||||
|
<input id="retention-control" type="range" min="0" max="100" value="15" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="control" for="volume-control">
|
||||||
|
<span class="control__heading">
|
||||||
|
<span>体量</span>
|
||||||
|
<output id="volume-value">100%</output>
|
||||||
|
</span>
|
||||||
|
<span class="control__hint">结果相对原文的总长度</span>
|
||||||
|
<input
|
||||||
|
id="volume-control"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="1000"
|
||||||
|
step="10"
|
||||||
|
value="100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="control" for="disorder-control">
|
||||||
|
<span class="control__heading">
|
||||||
|
<span>扰动</span>
|
||||||
|
<output id="disorder-value">58%</output>
|
||||||
|
</span>
|
||||||
|
<span class="control__hint">局部伸缩、重复与叠字强度</span>
|
||||||
|
<input id="disorder-control" type="range" min="0" max="100" value="58" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="keyword-stage" aria-labelledby="keyword-title">
|
||||||
|
<div class="keyword-stage__heading">
|
||||||
|
<div>
|
||||||
|
<span class="panel__number">03</span>
|
||||||
|
<h2 id="keyword-title">保留词</h2>
|
||||||
|
<p>点按词语切换显影状态,原始顺序不会改变。</p>
|
||||||
|
</div>
|
||||||
|
<button id="reselect-button" class="text-button" type="button">自动重选</button>
|
||||||
|
</div>
|
||||||
|
<div id="keyword-list" class="keyword-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>LOCAL PROCESSING</span>
|
||||||
|
<p>文本只在当前浏览器中处理,不会上传。</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||||
|
`
|
||||||
|
|
||||||
|
const sourceInput = getElement<HTMLTextAreaElement>('source-text')
|
||||||
|
const inputCount = getElement<HTMLSpanElement>('input-count')
|
||||||
|
const outputRatio = getElement<HTMLSpanElement>('output-ratio')
|
||||||
|
const mosaicOutput = getElement<HTMLDivElement>('mosaic-output')
|
||||||
|
const keywordList = getElement<HTMLDivElement>('keyword-list')
|
||||||
|
const toast = getElement<HTMLDivElement>('toast')
|
||||||
|
const retentionControl = getElement<HTMLInputElement>('retention-control')
|
||||||
|
const volumeControl = getElement<HTMLInputElement>('volume-control')
|
||||||
|
const disorderControl = getElement<HTMLInputElement>('disorder-control')
|
||||||
|
const retentionValue = getElement<HTMLOutputElement>('retention-value')
|
||||||
|
const volumeValue = getElement<HTMLOutputElement>('volume-value')
|
||||||
|
const disorderValue = getElement<HTMLOutputElement>('disorder-value')
|
||||||
|
const rerollButton = getElement<HTMLButtonElement>('reroll-button')
|
||||||
|
const copyButton = getElement<HTMLButtonElement>('copy-button')
|
||||||
|
const reselectButton = getElement<HTMLButtonElement>('reselect-button')
|
||||||
|
|
||||||
|
let candidates: KeywordCandidate[] = []
|
||||||
|
let selectionSeed = 5
|
||||||
|
let noiseSeed = Date.now()
|
||||||
|
let toastTimer = 0
|
||||||
|
|
||||||
|
function getElement<T extends HTMLElement>(id: string): T {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`找不到页面元素 #${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return element as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string): void {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.textContent = message
|
||||||
|
toast.classList.add('toast--visible')
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.classList.remove('toast--visible')
|
||||||
|
}, 1800)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRetention(): number {
|
||||||
|
return Number(retentionControl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeywords(): void {
|
||||||
|
keywordList.replaceChildren()
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
const empty = document.createElement('p')
|
||||||
|
empty.className = 'keyword-list__empty'
|
||||||
|
empty.textContent = sourceInput.value.trim()
|
||||||
|
? '没有识别到可保留的词语,可以尝试输入更长的中文文本。'
|
||||||
|
: '输入文字后,这里会出现可保留的词语。'
|
||||||
|
keywordList.append(empty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.className = 'keyword-token'
|
||||||
|
button.textContent = candidate.text
|
||||||
|
button.setAttribute('aria-pressed', String(candidate.selected))
|
||||||
|
|
||||||
|
if (candidate.selected) {
|
||||||
|
button.classList.add('keyword-token--selected')
|
||||||
|
}
|
||||||
|
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
candidate.selected = !candidate.selected
|
||||||
|
renderKeywords()
|
||||||
|
renderOutput()
|
||||||
|
})
|
||||||
|
keywordList.append(button)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCandidates(): void {
|
||||||
|
candidates = selectKeywords(sourceInput.value, getRetention(), selectionSeed)
|
||||||
|
renderKeywords()
|
||||||
|
renderOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOutput(): void {
|
||||||
|
const result = generateMosaic(sourceInput.value, candidates, {
|
||||||
|
volume: Number(volumeControl.value),
|
||||||
|
disorder: Number(disorderControl.value),
|
||||||
|
seed: noiseSeed,
|
||||||
|
})
|
||||||
|
|
||||||
|
mosaicOutput.textContent = result.output || '等待文字进入损坏区'
|
||||||
|
mosaicOutput.classList.toggle('mosaic-output--empty', result.output === '')
|
||||||
|
inputCount.textContent = `${result.inputLength} 字`
|
||||||
|
outputRatio.textContent =
|
||||||
|
result.inputLength === 0
|
||||||
|
? '—'
|
||||||
|
: `${result.outputLength} 字 / ${Math.round((result.outputLength / result.inputLength) * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncControls(): void {
|
||||||
|
retentionValue.value = `${retentionControl.value}%`
|
||||||
|
volumeValue.value = `${volumeControl.value}%`
|
||||||
|
disorderValue.value = `${disorderControl.value}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceInput.addEventListener('input', () => {
|
||||||
|
selectionSeed += 1
|
||||||
|
noiseSeed += 1
|
||||||
|
refreshCandidates()
|
||||||
|
})
|
||||||
|
|
||||||
|
retentionControl.addEventListener('input', () => {
|
||||||
|
syncControls()
|
||||||
|
refreshCandidates()
|
||||||
|
})
|
||||||
|
|
||||||
|
volumeControl.addEventListener('input', () => {
|
||||||
|
syncControls()
|
||||||
|
renderOutput()
|
||||||
|
})
|
||||||
|
|
||||||
|
disorderControl.addEventListener('input', () => {
|
||||||
|
syncControls()
|
||||||
|
renderOutput()
|
||||||
|
})
|
||||||
|
|
||||||
|
rerollButton.addEventListener('click', () => {
|
||||||
|
noiseSeed += 1
|
||||||
|
renderOutput()
|
||||||
|
})
|
||||||
|
|
||||||
|
reselectButton.addEventListener('click', () => {
|
||||||
|
selectionSeed += 1
|
||||||
|
refreshCandidates()
|
||||||
|
})
|
||||||
|
|
||||||
|
copyButton.addEventListener('click', async () => {
|
||||||
|
const text = mosaicOutput.textContent ?? ''
|
||||||
|
|
||||||
|
if (!sourceInput.value || !text) {
|
||||||
|
showToast('还没有可以复制的结果')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
showToast('已复制纯文本')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制文字马赛克失败', error)
|
||||||
|
showToast('复制失败,请手动选择文本')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
sourceInput.value = SAMPLE_TEXT
|
||||||
|
syncControls()
|
||||||
|
refreshCandidates()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import {
|
||||||
|
generateMosaic,
|
||||||
|
selectKeywords,
|
||||||
|
type KeywordCandidate,
|
||||||
|
} from './mosaic'
|
||||||
|
|
||||||
|
const SOURCE =
|
||||||
|
'我一直以为记忆会忠实地保存一切,后来才发现,每一次回想都在重新编造过去。'
|
||||||
|
|
||||||
|
function retain(
|
||||||
|
candidates: KeywordCandidate[],
|
||||||
|
words: string[],
|
||||||
|
): KeywordCandidate[] {
|
||||||
|
return candidates.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
selected: words.includes(candidate.text),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectKeywords', () => {
|
||||||
|
it('优先提供中文实词候选并排除常见停用词', () => {
|
||||||
|
const candidates = selectKeywords(SOURCE, 14, 1)
|
||||||
|
const words = candidates.map((candidate) => candidate.text)
|
||||||
|
|
||||||
|
expect(words).toContain('记忆')
|
||||||
|
expect(words).toContain('保存')
|
||||||
|
expect(words).toContain('回想')
|
||||||
|
expect(words).toContain('编造')
|
||||||
|
expect(words).not.toContain('一直')
|
||||||
|
expect(words).not.toContain('后来')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在有候选词时至少自动保留一个', () => {
|
||||||
|
const candidates = selectKeywords('风穿过废弃车站', 5, 1)
|
||||||
|
|
||||||
|
expect(candidates.some((candidate) => candidate.selected)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显影 0% 不保留词语,100% 保留全部候选词', () => {
|
||||||
|
const hidden = selectKeywords(SOURCE, 0, 1)
|
||||||
|
const revealed = selectKeywords(SOURCE, 100, 1)
|
||||||
|
|
||||||
|
expect(hidden.some((candidate) => candidate.selected)).toBe(false)
|
||||||
|
expect(revealed.every((candidate) => candidate.selected)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateMosaic', () => {
|
||||||
|
it('保留指定词语并彻底替换原标点', () => {
|
||||||
|
const candidates = retain(selectKeywords(SOURCE, 14, 1), [
|
||||||
|
'记忆',
|
||||||
|
'保存',
|
||||||
|
'回想',
|
||||||
|
'编造',
|
||||||
|
'过去',
|
||||||
|
])
|
||||||
|
const result = generateMosaic(SOURCE, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 58,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.output).toContain('记忆')
|
||||||
|
expect(result.output).toContain('保存')
|
||||||
|
expect(result.output).toContain('回想')
|
||||||
|
expect(result.output).toContain('编造')
|
||||||
|
expect(result.output).toContain('过去')
|
||||||
|
expect(result.output).not.toMatch(/[,。!?;:“”‘’()【】、]/u)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('将总可见长度维持在设定体量附近', () => {
|
||||||
|
const candidates = selectKeywords(SOURCE, 14, 1)
|
||||||
|
const result = generateMosaic(SOURCE, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 58,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
const ratio = result.outputLength / result.inputLength
|
||||||
|
|
||||||
|
expect(ratio).toBeGreaterThanOrEqual(0.97)
|
||||||
|
expect(ratio).toBeLessThanOrEqual(1.03)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('支持将体量压缩到 10% 或膨胀到 1000%', () => {
|
||||||
|
const compressed = generateMosaic(SOURCE, [], {
|
||||||
|
volume: 10,
|
||||||
|
disorder: 0,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
const expanded = generateMosaic(SOURCE, [], {
|
||||||
|
volume: 1000,
|
||||||
|
disorder: 0,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(compressed.outputLength).toBe(Math.round(compressed.inputLength * 0.1))
|
||||||
|
expect(expanded.outputLength).toBe(expanded.inputLength * 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不生成可能跳出正常行框的 Unicode 组合符', () => {
|
||||||
|
const outputs = Array.from({ length: 20 }, (_, seed) =>
|
||||||
|
generateMosaic(SOURCE, [], {
|
||||||
|
volume: 1000,
|
||||||
|
disorder: 100,
|
||||||
|
seed,
|
||||||
|
}).output,
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
expect(outputs).not.toMatch(/\p{Mark}/u)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('相同种子可复现,不同种子会重新侵蚀', () => {
|
||||||
|
const candidates = selectKeywords(SOURCE, 14, 1)
|
||||||
|
const first = generateMosaic(SOURCE, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 58,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
const repeated = generateMosaic(SOURCE, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 58,
|
||||||
|
seed: 42,
|
||||||
|
})
|
||||||
|
const rerolled = generateMosaic(SOURCE, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 58,
|
||||||
|
seed: 43,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(repeated.output).toBe(first.output)
|
||||||
|
expect(rerolled.output).not.toBe(first.output)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留换行但替换每一行中的隐藏内容', () => {
|
||||||
|
const source = '记忆,在这里。\n过去,在那里。'
|
||||||
|
const candidates = retain(selectKeywords(source, 20, 1), ['记忆', '过去'])
|
||||||
|
const result = generateMosaic(source, candidates, {
|
||||||
|
volume: 100,
|
||||||
|
disorder: 30,
|
||||||
|
seed: 7,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.output.split('\n')).toHaveLength(2)
|
||||||
|
expect(result.output).not.toMatch(/[,。]/u)
|
||||||
|
})
|
||||||
|
})
|
||||||
+556
@@ -0,0 +1,556 @@
|
|||||||
|
export interface KeywordCandidate {
|
||||||
|
id: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
score: number
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosaicOptions {
|
||||||
|
volume: number
|
||||||
|
disorder: number
|
||||||
|
seed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MosaicResult {
|
||||||
|
output: string
|
||||||
|
inputLength: number
|
||||||
|
outputLength: number
|
||||||
|
retainedLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutputPart {
|
||||||
|
type: 'hidden' | 'retained' | 'newline'
|
||||||
|
text: string
|
||||||
|
originalLength: number
|
||||||
|
noiseLength: number
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Random = () => number
|
||||||
|
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
'一个',
|
||||||
|
'一些',
|
||||||
|
'一种',
|
||||||
|
'不是',
|
||||||
|
'不能',
|
||||||
|
'不过',
|
||||||
|
'为了',
|
||||||
|
'之后',
|
||||||
|
'之前',
|
||||||
|
'于是',
|
||||||
|
'他们',
|
||||||
|
'但是',
|
||||||
|
'以及',
|
||||||
|
'仍然',
|
||||||
|
'什么',
|
||||||
|
'从而',
|
||||||
|
'以后',
|
||||||
|
'已经',
|
||||||
|
'以为',
|
||||||
|
'每一',
|
||||||
|
'你们',
|
||||||
|
'使得',
|
||||||
|
'关于',
|
||||||
|
'其实',
|
||||||
|
'再也',
|
||||||
|
'只是',
|
||||||
|
'可以',
|
||||||
|
'可能',
|
||||||
|
'后来',
|
||||||
|
'因为',
|
||||||
|
'如果',
|
||||||
|
'它们',
|
||||||
|
'对于',
|
||||||
|
'就是',
|
||||||
|
'并且',
|
||||||
|
'开始',
|
||||||
|
'我们',
|
||||||
|
'所以',
|
||||||
|
'所有',
|
||||||
|
'时候',
|
||||||
|
'是否',
|
||||||
|
'然后',
|
||||||
|
'现在',
|
||||||
|
'由于',
|
||||||
|
'直到',
|
||||||
|
'自己',
|
||||||
|
'都在',
|
||||||
|
'虽然',
|
||||||
|
'这个',
|
||||||
|
'这些',
|
||||||
|
'这种',
|
||||||
|
'那个',
|
||||||
|
'那些',
|
||||||
|
'那么',
|
||||||
|
'里面',
|
||||||
|
'重新',
|
||||||
|
'非常',
|
||||||
|
'没有',
|
||||||
|
'正在',
|
||||||
|
'能够',
|
||||||
|
'还是',
|
||||||
|
'进行',
|
||||||
|
'通过',
|
||||||
|
'需要',
|
||||||
|
'一起',
|
||||||
|
'一样',
|
||||||
|
'一般',
|
||||||
|
'一点',
|
||||||
|
'一次',
|
||||||
|
'一直',
|
||||||
|
'一切',
|
||||||
|
'不会',
|
||||||
|
'不要',
|
||||||
|
'不再',
|
||||||
|
'不只',
|
||||||
|
'不但',
|
||||||
|
'不管',
|
||||||
|
'不论',
|
||||||
|
'而且',
|
||||||
|
'或者',
|
||||||
|
'其中',
|
||||||
|
'其他',
|
||||||
|
'某个',
|
||||||
|
'某些',
|
||||||
|
'这里',
|
||||||
|
'那里',
|
||||||
|
'怎样',
|
||||||
|
'如何',
|
||||||
|
'为何',
|
||||||
|
'何时',
|
||||||
|
'我',
|
||||||
|
'你',
|
||||||
|
'他',
|
||||||
|
'她',
|
||||||
|
'它',
|
||||||
|
'这',
|
||||||
|
'那',
|
||||||
|
'是',
|
||||||
|
'的',
|
||||||
|
'地',
|
||||||
|
'得',
|
||||||
|
'了',
|
||||||
|
'着',
|
||||||
|
'过',
|
||||||
|
'在',
|
||||||
|
'和',
|
||||||
|
'与',
|
||||||
|
'及',
|
||||||
|
'而',
|
||||||
|
'也',
|
||||||
|
'都',
|
||||||
|
'就',
|
||||||
|
'又',
|
||||||
|
'还',
|
||||||
|
'才',
|
||||||
|
'次',
|
||||||
|
'被',
|
||||||
|
'把',
|
||||||
|
'将',
|
||||||
|
'会',
|
||||||
|
'能',
|
||||||
|
'可',
|
||||||
|
'不',
|
||||||
|
'没',
|
||||||
|
'很',
|
||||||
|
'更',
|
||||||
|
'最',
|
||||||
|
'让',
|
||||||
|
'从',
|
||||||
|
'到',
|
||||||
|
'对',
|
||||||
|
'于',
|
||||||
|
'以',
|
||||||
|
'为',
|
||||||
|
'所',
|
||||||
|
'其',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SIMPLE_HAN = [
|
||||||
|
'丌',
|
||||||
|
'乂',
|
||||||
|
'乜',
|
||||||
|
'亍',
|
||||||
|
'兀',
|
||||||
|
'仝',
|
||||||
|
'冇',
|
||||||
|
'夬',
|
||||||
|
'尢',
|
||||||
|
'孑',
|
||||||
|
'孓',
|
||||||
|
'廿',
|
||||||
|
'卅',
|
||||||
|
'卌',
|
||||||
|
'凼',
|
||||||
|
'彳',
|
||||||
|
'氕',
|
||||||
|
'氘',
|
||||||
|
'叵',
|
||||||
|
'玊',
|
||||||
|
'旡',
|
||||||
|
'乇',
|
||||||
|
'亓',
|
||||||
|
'伋',
|
||||||
|
'佀',
|
||||||
|
'佧',
|
||||||
|
'侘',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const GLITCH_SYMBOLS = [
|
||||||
|
'▒',
|
||||||
|
'▓',
|
||||||
|
'░',
|
||||||
|
'╳',
|
||||||
|
'⍉',
|
||||||
|
'⌁',
|
||||||
|
'⊘',
|
||||||
|
'⊗',
|
||||||
|
'⊙',
|
||||||
|
'∷',
|
||||||
|
'※',
|
||||||
|
'⌗',
|
||||||
|
'⎔',
|
||||||
|
'␀',
|
||||||
|
'␟',
|
||||||
|
'�',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const FOREIGN_GLYPHS = [
|
||||||
|
'Ж',
|
||||||
|
'Д',
|
||||||
|
'æ',
|
||||||
|
'Ø',
|
||||||
|
'λ',
|
||||||
|
'ϟ',
|
||||||
|
'҂',
|
||||||
|
'ヰ',
|
||||||
|
'ヱ',
|
||||||
|
'ヲ',
|
||||||
|
'ヵ',
|
||||||
|
'メ',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const ENCODING_FRAGMENTS = ['Ã', 'Â', 'æ', 'å', '¤', '锟', '斤', '拷'] as const
|
||||||
|
|
||||||
|
const COMPLEX_HAN = ['黻', '鬯', '夔', '爨', '龘', '靐', '罅', '黹'] as const
|
||||||
|
|
||||||
|
const HAN_PATTERN = /\p{Script=Han}/u
|
||||||
|
const WORD_PATTERN = /[\p{L}\p{N}]/u
|
||||||
|
|
||||||
|
const wordSegmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' })
|
||||||
|
const graphemeSegmenter = new Intl.Segmenter('zh-CN', {
|
||||||
|
granularity: 'grapheme',
|
||||||
|
})
|
||||||
|
|
||||||
|
function createRandom(seed: number): Random {
|
||||||
|
let state = seed >>> 0
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
state += 0x6d2b79f5
|
||||||
|
let value = state
|
||||||
|
value = Math.imul(value ^ (value >>> 15), value | 1)
|
||||||
|
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
||||||
|
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashText(value: string): number {
|
||||||
|
let hash = 2166136261
|
||||||
|
|
||||||
|
for (const character of value) {
|
||||||
|
hash ^= character.codePointAt(0) ?? 0
|
||||||
|
hash = Math.imul(hash, 16777619)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGraphemes(value: string): number {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const segment of graphemeSegmenter.segment(value)) {
|
||||||
|
if (segment.segment !== '\n' && segment.segment !== '\r') {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeywordCandidate(segment: string, isWordLike: boolean): boolean {
|
||||||
|
if (!isWordLike || STOP_WORDS.has(segment) || !WORD_PATTERN.test(segment)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = countGraphemes(segment)
|
||||||
|
|
||||||
|
if (HAN_PATTERN.test(segment)) {
|
||||||
|
return length >= 1 && length <= 8
|
||||||
|
}
|
||||||
|
|
||||||
|
return length >= 2 && length <= 16
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(
|
||||||
|
text: string,
|
||||||
|
start: number,
|
||||||
|
frequency: number,
|
||||||
|
selectionSeed: number,
|
||||||
|
): number {
|
||||||
|
const length = countGraphemes(text)
|
||||||
|
const lengthScore =
|
||||||
|
length === 2 ? 2.8 : length === 3 ? 2.4 : length === 4 ? 1.8 : 0.7
|
||||||
|
const frequencyScore = Math.min(frequency - 1, 3) * 1.2
|
||||||
|
const hanScore = HAN_PATTERN.test(text) ? 0.8 : 0.3
|
||||||
|
const random = createRandom(hashText(`${text}:${start}:${selectionSeed}`))()
|
||||||
|
|
||||||
|
return lengthScore + frequencyScore + hanScore + random * 1.4
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectKeywords(
|
||||||
|
text: string,
|
||||||
|
retention: number,
|
||||||
|
selectionSeed = 1,
|
||||||
|
): KeywordCandidate[] {
|
||||||
|
const segments = Array.from(wordSegmenter.segment(text))
|
||||||
|
const candidateSegments = segments.filter((segment) =>
|
||||||
|
isKeywordCandidate(segment.segment, Boolean(segment.isWordLike)),
|
||||||
|
)
|
||||||
|
const frequencies = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const segment of candidateSegments) {
|
||||||
|
frequencies.set(segment.segment, (frequencies.get(segment.segment) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = candidateSegments.map((segment) => ({
|
||||||
|
id: `${segment.index}:${segment.index + segment.segment.length}`,
|
||||||
|
start: segment.index,
|
||||||
|
end: segment.index + segment.segment.length,
|
||||||
|
text: segment.segment,
|
||||||
|
score: scoreCandidate(
|
||||||
|
segment.segment,
|
||||||
|
segment.index,
|
||||||
|
frequencies.get(segment.segment) ?? 1,
|
||||||
|
selectionSeed,
|
||||||
|
),
|
||||||
|
selected: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredCount =
|
||||||
|
retention <= 0
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
candidates.length,
|
||||||
|
Math.max(1, Math.ceil(candidates.length * (retention / 100))),
|
||||||
|
)
|
||||||
|
const ranked = [...candidates].sort(
|
||||||
|
(left, right) => right.score - left.score || left.start - right.start,
|
||||||
|
)
|
||||||
|
const selectedIds = new Set(
|
||||||
|
ranked.slice(0, desiredCount).map((candidate) => candidate.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return candidates.map((candidate) => ({
|
||||||
|
...candidate,
|
||||||
|
selected: selectedIds.has(candidate.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose<T>(values: readonly T[], random: Random): T {
|
||||||
|
return values[Math.floor(random() * values.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseNoiseGlyph(random: Random): string {
|
||||||
|
const categoryRoll = random() * 100
|
||||||
|
|
||||||
|
if (categoryRoll < 60) {
|
||||||
|
return choose(SIMPLE_HAN, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryRoll < 80) {
|
||||||
|
return choose(GLITCH_SYMBOLS, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryRoll < 90) {
|
||||||
|
return choose(FOREIGN_GLYPHS, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryRoll < 97) {
|
||||||
|
return choose(ENCODING_FRAGMENTS, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
return choose(COMPLEX_HAN, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNoise(length: number, disorder: number, random: Random): string {
|
||||||
|
let output = ''
|
||||||
|
let previous = ''
|
||||||
|
const repeatChance = 0.02 + disorder * 0.0015
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const shouldRepeat = previous !== '' && random() < repeatChance
|
||||||
|
const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random)
|
||||||
|
|
||||||
|
output += glyph
|
||||||
|
previous = glyph
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHiddenParts(parts: OutputPart[], text: string): void {
|
||||||
|
const lines = text.split(/(\r?\n)/)
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === '\n' || line === '\r\n') {
|
||||||
|
parts.push({
|
||||||
|
type: 'newline',
|
||||||
|
text: '\n',
|
||||||
|
originalLength: 0,
|
||||||
|
noiseLength: 0,
|
||||||
|
weight: 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = countGraphemes(line)
|
||||||
|
|
||||||
|
if (length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: 'hidden',
|
||||||
|
text: line,
|
||||||
|
originalLength: length,
|
||||||
|
noiseLength: 0,
|
||||||
|
weight: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOutputParts(
|
||||||
|
text: string,
|
||||||
|
retainedRanges: KeywordCandidate[],
|
||||||
|
): OutputPart[] {
|
||||||
|
const parts: OutputPart[] = []
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const range of retainedRanges) {
|
||||||
|
if (range.start > cursor) {
|
||||||
|
pushHiddenParts(parts, text.slice(cursor, range.start))
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
type: 'retained',
|
||||||
|
text: text.slice(range.start, range.end),
|
||||||
|
originalLength: countGraphemes(text.slice(range.start, range.end)),
|
||||||
|
noiseLength: 0,
|
||||||
|
weight: 0,
|
||||||
|
})
|
||||||
|
cursor = range.end
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor < text.length) {
|
||||||
|
pushHiddenParts(parts, text.slice(cursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function distributeNoiseBudget(
|
||||||
|
parts: OutputPart[],
|
||||||
|
budget: number,
|
||||||
|
disorder: number,
|
||||||
|
random: Random,
|
||||||
|
): void {
|
||||||
|
const hiddenParts = parts.filter(
|
||||||
|
(part) => part.type === 'hidden' && part.originalLength > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hiddenParts.length === 0 || budget <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseAllocation = budget >= hiddenParts.length ? 1 : 0
|
||||||
|
let remaining = budget - baseAllocation * hiddenParts.length
|
||||||
|
const variability = disorder / 100
|
||||||
|
|
||||||
|
for (const part of hiddenParts) {
|
||||||
|
const localScale = 1 + (random() * 2 - 1) * variability * 0.62
|
||||||
|
part.weight = Math.max(0.05, part.originalLength * localScale)
|
||||||
|
part.noiseLength = baseAllocation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
for (let index = 0; index < budget; index += 1) {
|
||||||
|
hiddenParts[index].noiseLength = 1
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWeight = hiddenParts.reduce((sum, part) => sum + part.weight, 0)
|
||||||
|
const allocations = hiddenParts.map((part) => {
|
||||||
|
const exact = (part.weight / totalWeight) * remaining
|
||||||
|
const whole = Math.floor(exact)
|
||||||
|
part.noiseLength += whole
|
||||||
|
return { part, fraction: exact - whole }
|
||||||
|
})
|
||||||
|
const allocated = allocations.reduce(
|
||||||
|
(sum, allocation) => sum + Math.floor((allocation.part.weight / totalWeight) * remaining),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining -= allocated
|
||||||
|
allocations.sort((left, right) => right.fraction - left.fraction)
|
||||||
|
|
||||||
|
for (let index = 0; index < remaining; index += 1) {
|
||||||
|
allocations[index % allocations.length].part.noiseLength += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMosaic(
|
||||||
|
text: string,
|
||||||
|
candidates: KeywordCandidate[],
|
||||||
|
options: MosaicOptions,
|
||||||
|
): MosaicResult {
|
||||||
|
const random = createRandom(options.seed ^ hashText(text))
|
||||||
|
const retainedRanges = candidates
|
||||||
|
.filter((candidate) => candidate.selected)
|
||||||
|
.sort((left, right) => left.start - right.start)
|
||||||
|
const parts = buildOutputParts(text, retainedRanges)
|
||||||
|
const inputLength = countGraphemes(text)
|
||||||
|
const retainedLength = parts
|
||||||
|
.filter((part) => part.type === 'retained')
|
||||||
|
.reduce((sum, part) => sum + part.originalLength, 0)
|
||||||
|
const jitter = (random() * 2 - 1) * (options.disorder / 100) * 0.035
|
||||||
|
const targetLength = Math.max(
|
||||||
|
retainedLength,
|
||||||
|
Math.round(inputLength * (options.volume / 100 + jitter)),
|
||||||
|
)
|
||||||
|
const noiseBudget = Math.max(0, targetLength - retainedLength)
|
||||||
|
|
||||||
|
distributeNoiseBudget(parts, noiseBudget, options.disorder, random)
|
||||||
|
|
||||||
|
const output = parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.type === 'hidden') {
|
||||||
|
return generateNoise(part.noiseLength, options.disorder, random)
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.text
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return {
|
||||||
|
output,
|
||||||
|
inputLength,
|
||||||
|
outputLength: countGraphemes(output),
|
||||||
|
retainedLength,
|
||||||
|
}
|
||||||
|
}
|
||||||
+637
@@ -0,0 +1,637 @@
|
|||||||
|
:root {
|
||||||
|
color: #171815;
|
||||||
|
background: #e9e7dd;
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
--ink: #171815;
|
||||||
|
--paper: #e9e7dd;
|
||||||
|
--acid: #d8ff52;
|
||||||
|
--muted: #6b6b63;
|
||||||
|
--line: rgba(23, 24, 21, 0.24);
|
||||||
|
--grid-row: rgba(23, 24, 21, 0.035);
|
||||||
|
--grid-column: rgba(23, 24, 21, 0.025);
|
||||||
|
--panel: rgba(244, 242, 234, 0.76);
|
||||||
|
--panel-soft: rgba(244, 242, 234, 0.7);
|
||||||
|
--shadow: var(--ink);
|
||||||
|
--focus-inset: rgba(23, 24, 21, 0.28);
|
||||||
|
--placeholder: #9b9a91;
|
||||||
|
--output: #171815;
|
||||||
|
--output-ink: #efeee6;
|
||||||
|
--output-muted: #9c9d93;
|
||||||
|
--output-empty: #77786f;
|
||||||
|
--output-line: rgba(255, 255, 255, 0.2);
|
||||||
|
--output-glow: rgba(216, 255, 82, 0.08);
|
||||||
|
--output-hover: #efeee6;
|
||||||
|
--output-hover-ink: #171815;
|
||||||
|
--acid-hover: #efffb4;
|
||||||
|
--on-acid: #171815;
|
||||||
|
--selected: var(--ink);
|
||||||
|
--selected-ink: var(--acid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color: #efeee6;
|
||||||
|
background: #121310;
|
||||||
|
--ink: #efeee6;
|
||||||
|
--paper: #121310;
|
||||||
|
--acid: #cffa4c;
|
||||||
|
--muted: #a2a39a;
|
||||||
|
--line: rgba(239, 238, 230, 0.23);
|
||||||
|
--grid-row: rgba(239, 238, 230, 0.04);
|
||||||
|
--grid-column: rgba(239, 238, 230, 0.03);
|
||||||
|
--panel: rgba(29, 30, 26, 0.82);
|
||||||
|
--panel-soft: rgba(29, 30, 26, 0.76);
|
||||||
|
--shadow: #050604;
|
||||||
|
--focus-inset: rgba(239, 238, 230, 0.3);
|
||||||
|
--placeholder: #8e8f86;
|
||||||
|
--output: #090a08;
|
||||||
|
--output-glow: rgba(207, 250, 76, 0.11);
|
||||||
|
--acid-hover: #e7ffa0;
|
||||||
|
--selected: var(--acid);
|
||||||
|
--selected-ink: var(--on-acid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--grid-row) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-column) 1px, transparent 1px),
|
||||||
|
var(--paper);
|
||||||
|
background-size: 100% 48px, 48px 100%, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible {
|
||||||
|
outline: 3px solid var(--acid);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--focus-inset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
width: min(1440px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px clamp(20px, 4vw, 64px) 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
row-gap: clamp(20px, 3vw, 38px);
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 22px 0 30px;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__index {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__content {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-serif, "Noto Serif SC", "Songti SC", serif;
|
||||||
|
font-size: clamp(58px, 9vw, 132px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.88;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 25px 0 0 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-serif, "Noto Serif SC", "Songti SC", serif;
|
||||||
|
font-size: clamp(16px, 2vw, 22px);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__mark {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
align-self: end;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
font-size: clamp(64px, 9vw, 124px);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
box-shadow: 9px 9px 0 var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--output {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 18%, var(--output-glow), transparent 25%),
|
||||||
|
var(--output);
|
||||||
|
color: var(--output-ink);
|
||||||
|
border-left: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 76px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__heading > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__heading--dark {
|
||||||
|
border-color: var(--output-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__heading h2,
|
||||||
|
.keyword-stage h2,
|
||||||
|
.control-deck h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__number {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--output .panel__number {
|
||||||
|
color: var(--output-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--output .counter {
|
||||||
|
color: var(--output-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 340px;
|
||||||
|
padding: 28px 30px 34px;
|
||||||
|
resize: none;
|
||||||
|
color: var(--ink);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font-family: ui-serif, "Noto Serif SC", "Songti SC", serif;
|
||||||
|
font-size: clamp(20px, 2.1vw, 28px);
|
||||||
|
line-height: 1.85;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mosaic-output {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 316px;
|
||||||
|
padding: 30px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family:
|
||||||
|
"Noto Serif SC", "Songti SC", ui-serif, "Courier New", serif;
|
||||||
|
font-size: clamp(21px, 2.25vw, 31px);
|
||||||
|
line-height: 1.75;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mosaic-output--empty {
|
||||||
|
color: var(--output-empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
border-top: 1px solid var(--output-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
transition:
|
||||||
|
color 160ms ease,
|
||||||
|
background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--light {
|
||||||
|
background: var(--acid);
|
||||||
|
color: var(--on-acid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--light:hover {
|
||||||
|
background: var(--acid-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--outline {
|
||||||
|
color: var(--output-ink);
|
||||||
|
background: transparent;
|
||||||
|
border-left: 1px solid var(--output-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--outline:hover {
|
||||||
|
color: var(--output-hover-ink);
|
||||||
|
background: var(--output-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-deck {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 0.7fr) 2fr;
|
||||||
|
gap: 40px;
|
||||||
|
margin-top: 76px;
|
||||||
|
padding: 26px 0 38px;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
border-bottom: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-deck__title > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control__heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control__heading output {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control__hint {
|
||||||
|
display: block;
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-track {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: -8px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--acid);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--acid);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage {
|
||||||
|
margin-top: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage__heading > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage__heading p {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 9px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button {
|
||||||
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-button:hover {
|
||||||
|
border-bottom-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 110px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--panel-soft);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-token {
|
||||||
|
min-width: 46px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-family: ui-serif, "Noto Serif SC", "Songti SC", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
transition:
|
||||||
|
transform 120ms ease,
|
||||||
|
color 120ms ease,
|
||||||
|
background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-token:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-token--selected {
|
||||||
|
color: var(--selected-ink);
|
||||||
|
background: var(--selected);
|
||||||
|
border-color: var(--selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-list__empty {
|
||||||
|
margin: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 96px;
|
||||||
|
padding-top: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 13px 18px;
|
||||||
|
color: var(--on-acid);
|
||||||
|
background: var(--acid);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
box-shadow: 5px 5px 0 var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.page-shell {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
min-height: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__mark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
box-shadow: 6px 6px 0 var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--output {
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
border-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-deck {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page-shell {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
min-height: 164px;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(52px, 17vw, 78px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__heading {
|
||||||
|
min-height: 66px;
|
||||||
|
padding: 15px 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea,
|
||||||
|
.mosaic-output {
|
||||||
|
min-height: 290px;
|
||||||
|
padding: 22px 18px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control__hint {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage {
|
||||||
|
margin-top: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage__heading {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-stage__heading p {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-list {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable", "ES2022.Intl"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user