Compare commits
1 Commits
main
..
0001f72635
| Author | SHA1 | Date | |
|---|---|---|---|
| 0001f72635 |
@@ -1,5 +0,0 @@
|
|||||||
.git
|
|
||||||
.worktrees
|
|
||||||
dist
|
|
||||||
node_modules
|
|
||||||
*.log
|
|
||||||
+1
-2
@@ -1,6 +1,4 @@
|
|||||||
# ---> Node
|
# ---> Node
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -137,3 +135,4 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
|||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,29 +1,3 @@
|
|||||||
# 文字马赛克
|
# text-mosaic
|
||||||
|
|
||||||
将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
|
将中文文本转化为保留少量关键词的乱码缺损艺术
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
- 使用浏览器原生中文分词自动挑选保留词
|
|
||||||
- 支持手动切换每个候选词的显影状态
|
|
||||||
- 标点和未保留文本会被完全替换
|
|
||||||
- 混合低笔画少用汉字、故障符号、异文字母和编码残片
|
|
||||||
- 总体长度接近原文,同时允许局部片段随机伸缩
|
|
||||||
- 同一组参数可反复生成不同结果并复制为纯文本
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
<!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
@@ -1,28 +0,0 @@
|
|||||||
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
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "text-mosaic",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "text-mosaic",
|
|
||||||
"version": "0.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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/`)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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
@@ -1 +0,0 @@
|
|||||||
declare module '*.css'
|
|
||||||
-287
@@ -1,287 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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
@@ -1,556 +0,0 @@
|
|||||||
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
@@ -1,637 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"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