feat: 初始化文字马赛克生成器 #1
+2
-1
@@ -1,4 +1,6 @@
|
||||
# ---> Node
|
||||
.worktrees/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -135,4 +137,3 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
# text-mosaic
|
||||
# 文字马赛克
|
||||
|
||||
将中文文本转化为保留少量关键词的乱码缺损艺术
|
||||
将中文文本转化为保留少量关键词的乱码缺损艺术。所有处理均在浏览器本地完成。
|
||||
|
||||
## 特性
|
||||
|
||||
- 使用浏览器原生中文分词自动挑选保留词
|
||||
- 支持手动切换每个候选词的显影状态
|
||||
- 标点和未保留文本会被完全替换
|
||||
- 混合低笔画少用汉字、故障符号、异文字母和编码残片
|
||||
- 总体长度接近原文,同时允许局部片段随机伸缩
|
||||
- 同一组参数可反复生成不同结果并复制为纯文本
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
bun test
|
||||
bun run build
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<!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" />
|
||||
<title>文字马赛克</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
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">14%</output>
|
||||
</span>
|
||||
<span class="control__hint">留下多少原文词语</span>
|
||||
<input id="retention-control" type="range" min="5" max="40" value="14" />
|
||||
</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="85" max="115" 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,111 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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('相同种子可复现,不同种子会重新侵蚀', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
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 COMBINING_MARKS = ['\u0336', '\u0337', '\u0338', '\u035F'] 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 = Math.min(
|
||||
candidates.length,
|
||||
Math.max(
|
||||
candidates.length >= 4 ? 2 : 1,
|
||||
Math.round(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
|
||||
const combiningChance = 0.02 + disorder * 0.0011
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const shouldRepeat = previous !== '' && random() < repeatChance
|
||||
const glyph = shouldRepeat ? previous : chooseNoiseGlyph(random)
|
||||
const combining =
|
||||
random() < combiningChance ? choose(COMBINING_MARKS, random) : ''
|
||||
|
||||
output += `${glyph}${combining}`
|
||||
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,
|
||||
}
|
||||
}
|
||||
+585
@@ -0,0 +1,585 @@
|
||||
:root {
|
||||
color: #171815;
|
||||
background: #e9e7dd;
|
||||
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);
|
||||
}
|
||||
|
||||
* {
|
||||
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(rgba(23, 24, 21, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(23, 24, 21, 0.025) 1px, transparent 1px),
|
||||
var(--paper);
|
||||
background-size: 100% 48px, 48px 100%, auto;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
textarea:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 3px solid var(--acid);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
width: min(1440px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 32px clamp(20px, 4vw, 64px) 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
min-height: 220px;
|
||||
padding: 22px 0 30px;
|
||||
border-top: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.hero__index {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
left: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.hero__content {
|
||||
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 {
|
||||
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(--ink);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.panel--input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(244, 242, 234, 0.76);
|
||||
}
|
||||
|
||||
.panel--output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(circle at 15% 18%, rgba(216, 255, 82, 0.08), transparent 25%),
|
||||
var(--ink);
|
||||
color: #efeee6;
|
||||
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: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.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: #9c9d93;
|
||||
}
|
||||
|
||||
.counter {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel--output .counter {
|
||||
color: #9c9d93;
|
||||
}
|
||||
|
||||
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: #9b9a91;
|
||||
}
|
||||
|
||||
.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: #77786f;
|
||||
}
|
||||
|
||||
.output-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.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(--ink);
|
||||
}
|
||||
|
||||
.button--light:hover {
|
||||
background: #efffb4;
|
||||
}
|
||||
|
||||
.button--outline {
|
||||
color: #efeee6;
|
||||
background: transparent;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.button--outline:hover {
|
||||
color: var(--ink);
|
||||
background: #efeee6;
|
||||
}
|
||||
|
||||
.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: rgba(244, 242, 234, 0.7);
|
||||
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(--acid);
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.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(--ink);
|
||||
background: var(--acid);
|
||||
border: 1px solid var(--ink);
|
||||
box-shadow: 5px 5px 0 var(--ink);
|
||||
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(--ink);
|
||||
}
|
||||
|
||||
.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