@jjlmoya/utils-language 1.10.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -1
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/registry_contract.test.ts +67 -0
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/lexical-coverage-vocabulary-calculator/bibliography.astro +6 -0
  8. package/src/tool/lexical-coverage-vocabulary-calculator/bibliography.ts +6 -0
  9. package/src/tool/lexical-coverage-vocabulary-calculator/component.astro +77 -0
  10. package/src/tool/lexical-coverage-vocabulary-calculator/controller.ts +157 -0
  11. package/src/tool/lexical-coverage-vocabulary-calculator/dom-views.ts +158 -0
  12. package/src/tool/lexical-coverage-vocabulary-calculator/entry.ts +34 -0
  13. package/src/tool/lexical-coverage-vocabulary-calculator/evaluator.ts +13 -0
  14. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/de.ts +13 -0
  15. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/en.ts +59 -0
  16. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/es.ts +13 -0
  17. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/fr.ts +53 -0
  18. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/id.ts +13 -0
  19. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/it.ts +13 -0
  20. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ja.ts +13 -0
  21. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ko.ts +13 -0
  22. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/nl.ts +13 -0
  23. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/pl.ts +13 -0
  24. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/pt.ts +13 -0
  25. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/ru.ts +13 -0
  26. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/sv.ts +13 -0
  27. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/tr.ts +59 -0
  28. package/src/tool/lexical-coverage-vocabulary-calculator/i18n/zh.ts +13 -0
  29. package/src/tool/lexical-coverage-vocabulary-calculator/index.ts +14 -0
  30. package/src/tool/lexical-coverage-vocabulary-calculator/lexical-coverage-vocabulary-calculator.css +497 -0
  31. package/src/tool/lexical-coverage-vocabulary-calculator/logic.test.ts +35 -0
  32. package/src/tool/lexical-coverage-vocabulary-calculator/logic.ts +76 -0
  33. package/src/tool/lexical-coverage-vocabulary-calculator/seo.astro +11 -0
  34. package/src/tool/lexical-coverage-vocabulary-calculator/storage.ts +24 -0
  35. package/src/tool/lexical-coverage-vocabulary-calculator/types.ts +33 -0
  36. package/src/tool/lexical-coverage-vocabulary-calculator/ui.ts +50 -0
  37. package/src/tool/lexical-coverage-vocabulary-calculator/validation.ts +18 -0
  38. package/src/tools.ts +2 -1
@@ -0,0 +1,13 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import type { LexicalCoverageUI } from '../ui';
4
+
5
+ const ui: LexicalCoverageUI = { contentType: '你想理解什么内容?', conversation: '日常对话', travel: '旅行与日常生活', media: '电影与剧集', generalReading: '一般阅读', novels: '小说', business: '商务与工作', academic: '学术文本', knownWords: '你已经认识的词', knownWordsHint: '尽量使用有依据的估计,并始终统一计算词族、词元或词形中的一种单位。', targetCoverage: '目标覆盖率', coverage80: '开始理解', coverage90: '跟上脉络', coverage95: '借助支持阅读', coverage98: '独立阅读', resetButton: '重置', quickStart: '快速开始', conversationPreset: '对话', readingPreset: '小说', workPreset: '工作', resultTitle: '你的词汇地图', emptyResult: '选择内容类型并输入已知词汇,即可看到下一个实用的词汇目标。', checkInputs: '请检查输入', invalidWords: '请输入0到50,000之间的已知词数。', invalidContentType: '请选择可用的内容类型。', invalidCoverage: '请选择80%到98%之间的目标。', belowTarget: '你还没有达到选择的覆盖率。差距就是下一个词汇项目。', atTarget: '你已经达到选择的计划区间。请把主题词汇放在手边。', buffer: '对于这个内容类型,你已经有足够余量。现在用真实目标文本进行检查。', recommendedWords: '目标所需词数', vocabularyKnown: '已计入词数', estimatedCoverage: '预计覆盖率', gapWords: '距离目标还差', unknownPerHundred: '每100词中的生词', coverageRoute: '从高频词到独立阅读的路线', nextCheckpoint: '下一个检查点', noMoreCheckpoints: '你已经超过这个计划模型的最高检查点。', words: '词', wordUnit: '词', unknownWords: '生词', targetMarker: '目标', coverageNote: '覆盖率是文本中已知词汇能够识别的连续词语比例。它不等同于语言熟练度,也不代表理解了相同比例的意义。', wordFamilyNote: '研究常常计算词族,而应用可能计算词元或词形。比较时要使用相同单位。', contentTypeHelp: '不同体裁需要不同词汇量。对话会重复高频词,而小说、工作和学习材料会加入更多低频及专业词汇。', interpretationTitle: '如何使用地图', actionBelow: '从真实对话、书籍或工作文档中整理重复出现的词,并把它们放在短语中学习。', actionAt: '查看目标体裁的一页内容或文字稿,加入反复出现的生词。', actionBuffer: '不要只收集孤立单词,也要收集下一篇文本需要的短语、搭配和专业词汇。' };
6
+ const faq = [{ question: '词汇覆盖率计算器估计什么?', answer: '它按80%、90%、95%和98%的计划覆盖率,估计理解对话、媒体、小说、工作或学术文本所需的词汇量,并显示下一个检查点。' }, { question: '为什么小说比对话需要更多词汇?', answer: '日常对话会重复高频核心词。小说和专业文本包含更多低频词、主题词和固定表达,所以相同词数覆盖的文本更少。' }, { question: '80%的覆盖率意味着理解了80%的意思吗?', answer: '不意味着。它只是表示大约有多少连续词语能够被识别。关键词、语法、背景知识、发音和语速同样影响理解。' }, { question: '应该计算词、词元还是词族?', answer: '选择一种单位并保持一致。研究常报告词族,而应用可能报告词元或词形,这些数字不能直接互换。' }, { question: '差距应该怎么使用?', answer: '从目标体裁中抽取短样本,保留反复出现的词并放在短语中学习,然后再检查相似样本。' }];
7
+ const howTo = [{ name: '选择内容', text: '选择你接下来想理解的对话或文本类型。' }, { name: '估计词汇量', text: '输入你能识别的词、词元或词族的统一估计值。' }, { name: '设定覆盖率', text: '从开始理解到独立阅读,选择适合你的计划区间。' }, { name: '利用差距', text: '把差距和下一个目标变成来自真实内容的学习清单。' }];
8
+ const appSchema: SoftwareApplication = { '@type': 'SoftwareApplication', name: '词汇覆盖率计算器', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', isAccessibleForFree: true, url: 'https://gamebob.dev/zh/lexical-coverage-vocabulary-calculator' };
9
+ const howToSchema: HowTo = { '@type': 'HowTo', name: '按内容类型估计所需词汇量', step: howTo.map((item) => ({ '@type': 'HowToStep', ...item })) };
10
+ const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
11
+ const withContext = (schema: SoftwareApplication | HowTo | FAQPage): Record<string, unknown> => ({ '@context': 'https://schema.org', ...schema });
12
+
13
+ export const content: ToolLocaleContent<LexicalCoverageUI> = { slug: 'lexical-coverage-vocabulary-calculator', title: '词汇覆盖率计算器', description: '根据覆盖率目标,估计理解对话、小说、工作、媒体和学术文本所需的词汇量。', ui, seo: [{ type: 'title', text: '按内容规划所需词汇量', level: 2 }, { type: 'paragraph', html: '少量高频词可以打开日常对话,却不能同样覆盖小说或专业文档。这款计算器把目标体裁连接到具体词数和下一个可执行的目标。' }, { type: 'title', text: '词汇覆盖率衡量什么', level: 2 }, { type: 'paragraph', html: '词汇覆盖率是样本中已知词汇能够识别的连续词语比例。80%、90%、95%和98%是规划区间,不是考试,也不保证完整理解。' }, { type: 'title', text: '为什么体裁会改变数字', level: 2 }, { type: 'table', headers: ['内容', '80%计划区间', '95%计划区间', '增加的内容'], rows: [['日常对话', '2,000词', '4,500词', '日常套路和高频表达'], ['电影与剧集', '3,000词', '6,000词', '对话、俚语和话题变化'], ['小说', '5,000词', '8,000词', '叙事、描写和体裁词汇'], ['商务与工作', '5,000词', '8,000词', '专业表达和领域术语'], ['学术文本', '6,000词', '10,000词', '抽象词和学科术语']] }, { type: 'title', text: '把差距变成学习决定', level: 2 }, { type: 'paragraph', html: '如果低于选择的区间,不要随机收集单词。从真实内容中抽取短样本,保留反复出现或阻碍理解的词,并把它们放在短语中学习。' }, { type: 'list', items: ['选择与你下一项真实任务匹配的体裁。', '每次都使用相同的计算单位。', '优先学习在多个样本中重复出现的词。', '学习后再次检查同一种内容。'] }, { type: 'tip', title: '依据与限制', html: '80%覆盖率对应2,000词的参考来自词频和文本覆盖研究。更高的书面语数值会因语料库和体裁而变化。语言、形态、专名、主题知识、语法和速度仍会影响理解。' }], faq, bibliography: [{ name: 'Paul Nation, How Large a Vocabulary Is Needed for Reading and Listening?', url: 'https://www.scienceguide.nl/wp-content/uploads/2017/11/nation-2006-vocabulary.pdf' }, { name: 'Izquierdo Gil, La selección del léxico en la enseñanza del español como lengua extranjera', url: 'https://asele.org/media/2025/03/8izquierdogilm-adelcarmenlaselecciondellexicoenlaensananza.pdf' }], howTo, schemas: [withContext(appSchema), withContext(faqSchema), withContext(howToSchema)] };
@@ -0,0 +1,14 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { lexicalCoverageVocabularyCalculator } from './entry';
3
+
4
+ export type { LexicalCoverageUI, LexicalCoverageVocabularyCalculatorLocaleContent } from './entry';
5
+ export { lexicalCoverageVocabularyCalculator } from './entry';
6
+ export { calculateLexicalCoverage, estimateCoverage, validateInputs, COVERAGE_TARGETS, VOCABULARY_PRESETS } from './logic';
7
+ export type { ContentType, CoverageStatus, CoverageTarget, LexicalCoverageInputs, LexicalCoveragePlan, LexicalCoverageResult, VocabularyPreset } from './types';
8
+
9
+ export const LEXICAL_COVERAGE_VOCABULARY_CALCULATOR_TOOL: ToolDefinition = {
10
+ entry: lexicalCoverageVocabularyCalculator,
11
+ Component: () => import('./component.astro'),
12
+ SEOComponent: () => import('./seo.astro'),
13
+ BibliographyComponent: () => import('./bibliography.astro'),
14
+ };
@@ -0,0 +1,497 @@
1
+ :root {
2
+ --n-paper: #f3eee3;
3
+ --n-card: #fffdf8;
4
+ --n-white: #fff;
5
+ --n-ink: #202c33;
6
+ --n-muted: #66747a;
7
+ --n-line: #d8cec0;
8
+ --n-accent: #b7523d;
9
+ --n-accent-strong: #813427;
10
+ --n-accent-soft: #f1d8ce;
11
+ --n-water: #2b6870;
12
+ --n-water-soft: #d9ece8;
13
+ --n-good: #2e7051;
14
+ --n-shadow: rgba(44, 38, 28, 0.12);
15
+ }
16
+
17
+ .theme-dark {
18
+ --n-paper: #181e22;
19
+ --n-card: #242d32;
20
+ --n-white: #fff;
21
+ --n-ink: #f5eddf;
22
+ --n-muted: #bec6c5;
23
+ --n-line: #4d5d60;
24
+ --n-accent: #f09278;
25
+ --n-accent-strong: #ffb09a;
26
+ --n-accent-soft: #5a3733;
27
+ --n-water: #8fd0cc;
28
+ --n-water-soft: #29464a;
29
+ --n-good: #94d1a9;
30
+ --n-shadow: rgba(0, 0, 0, 0.3);
31
+ }
32
+
33
+ .lexical-coverage {
34
+ --n-paper: #f3eee3;
35
+ --n-card: #fffdf8;
36
+ --n-white: #fff;
37
+ --n-ink: #202c33;
38
+ --n-muted: #66747a;
39
+ --n-line: #d8cec0;
40
+ --n-accent: #b7523d;
41
+ --n-accent-strong: #813427;
42
+ --n-accent-soft: #f1d8ce;
43
+ --n-water: #2b6870;
44
+ --n-water-soft: #d9ece8;
45
+ --n-good: #2e7051;
46
+ --n-shadow: rgba(44, 38, 28, 0.12);
47
+
48
+ display: grid;
49
+ grid-template-columns: minmax(16rem, 0.75fr) minmax(24rem, 1.25fr);
50
+ color: var(--n-ink);
51
+ background: var(--n-paper);
52
+ border: 1px solid var(--n-line);
53
+ border-radius: 1.25rem;
54
+ box-shadow: 0 1rem 2rem var(--n-shadow);
55
+ overflow: hidden;
56
+ }
57
+
58
+ .theme-dark .lexical-coverage {
59
+ --n-paper: #181e22;
60
+ --n-card: #242d32;
61
+ --n-white: #fff;
62
+ --n-ink: #f5eddf;
63
+ --n-muted: #bec6c5;
64
+ --n-line: #4d5d60;
65
+ --n-accent: #f09278;
66
+ --n-accent-strong: #ffb09a;
67
+ --n-accent-soft: #5a3733;
68
+ --n-water: #8fd0cc;
69
+ --n-water-soft: #29464a;
70
+ --n-good: #94d1a9;
71
+ --n-shadow: rgba(0, 0, 0, 0.3);
72
+ }
73
+
74
+ .lexical-form,
75
+ .lexical-result {
76
+ padding: 1.5rem;
77
+ }
78
+
79
+ .lexical-form {
80
+ display: grid;
81
+ align-content: start;
82
+ gap: 1.25rem;
83
+ border-right: 1px solid var(--n-line);
84
+ }
85
+
86
+ .field-label,
87
+ .coverage-label,
88
+ .preset-label {
89
+ display: block;
90
+ margin-bottom: 0.45rem;
91
+ color: var(--n-muted);
92
+ font-size: 0.75rem;
93
+ font-weight: 800;
94
+ letter-spacing: 0.06em;
95
+ text-transform: uppercase;
96
+ }
97
+
98
+ .content-field,
99
+ .choice,
100
+ .words-field {
101
+ position: relative;
102
+ }
103
+
104
+ .choice-trigger,
105
+ .words-field input[type='number'] {
106
+ width: 100%;
107
+ min-height: 3.1rem;
108
+ border: 1px solid var(--n-line);
109
+ border-radius: 0.7rem;
110
+ background: var(--n-card);
111
+ color: var(--n-ink);
112
+ font: inherit;
113
+ font-size: 1rem;
114
+ padding: 0.75rem 0.85rem;
115
+ }
116
+
117
+ .choice-trigger {
118
+ cursor: pointer;
119
+ text-align: left;
120
+ }
121
+
122
+ .choice-trigger::after {
123
+ content: '';
124
+ float: right;
125
+ width: 0.55rem;
126
+ height: 0.55rem;
127
+ margin-top: 0.1rem;
128
+ border-right: 2px solid var(--n-accent);
129
+ border-bottom: 2px solid var(--n-accent);
130
+ transform: rotate(45deg);
131
+ }
132
+
133
+ .choice-trigger[aria-expanded='true']::after {
134
+ transform: rotate(225deg) translate(-0.1rem, -0.1rem);
135
+ }
136
+
137
+ .choice-menu {
138
+ position: absolute;
139
+ z-index: 3;
140
+ top: calc(100% + 0.35rem);
141
+ right: 0;
142
+ left: 0;
143
+ display: grid;
144
+ gap: 0.2rem;
145
+ padding: 0.35rem;
146
+ border: 1px solid var(--n-line);
147
+ border-radius: 0.7rem;
148
+ background: var(--n-card);
149
+ box-shadow: 0 0.8rem 1.5rem var(--n-shadow);
150
+ }
151
+
152
+ .choice-menu[hidden] {
153
+ display: none;
154
+ }
155
+
156
+ .choice-menu button {
157
+ border: 0;
158
+ border-radius: 0.45rem;
159
+ background: transparent;
160
+ color: var(--n-ink);
161
+ cursor: pointer;
162
+ font: inherit;
163
+ padding: 0.65rem;
164
+ text-align: left;
165
+ }
166
+
167
+ .choice-menu button:hover,
168
+ .choice-menu button[aria-selected='true'] {
169
+ background: var(--n-accent-soft);
170
+ }
171
+
172
+ .words-field input[type='number'] {
173
+ padding-right: 4.5rem;
174
+ }
175
+
176
+ .words-range {
177
+ width: 100%;
178
+ accent-color: var(--n-accent);
179
+ }
180
+
181
+ .words-hint,
182
+ .lexical-note {
183
+ margin: 0.45rem 0 0;
184
+ color: var(--n-muted);
185
+ font-size: 0.83rem;
186
+ line-height: 1.45;
187
+ }
188
+
189
+ .coverage-options {
190
+ display: grid;
191
+ grid-template-columns: repeat(4, 1fr);
192
+ gap: 0.45rem;
193
+ }
194
+
195
+ .coverage-options button,
196
+ .preset-grid button,
197
+ .lexical-actions button {
198
+ border: 1px solid var(--n-line);
199
+ border-radius: 0.6rem;
200
+ background: var(--n-card);
201
+ color: var(--n-ink);
202
+ cursor: pointer;
203
+ font: inherit;
204
+ font-weight: 700;
205
+ padding: 0.7rem 0.45rem;
206
+ }
207
+
208
+ .coverage-options button[aria-pressed='true'] {
209
+ border-color: var(--n-accent);
210
+ background: var(--n-accent-soft);
211
+ color: var(--n-accent-strong);
212
+ }
213
+
214
+ .preset-grid {
215
+ display: grid;
216
+ grid-template-columns: repeat(3, 1fr);
217
+ gap: 0.45rem;
218
+ }
219
+
220
+ .preset-grid button {
221
+ color: var(--n-water);
222
+ font-size: 0.85rem;
223
+ }
224
+
225
+ .lexical-actions {
226
+ display: flex;
227
+ gap: 0.6rem;
228
+ }
229
+
230
+ .lexical-actions .reset-button {
231
+ border-color: transparent;
232
+ background: transparent;
233
+ color: var(--n-muted);
234
+ font-size: 0.85rem;
235
+ font-weight: 600;
236
+ }
237
+
238
+ .lexical-actions .reset-button:hover {
239
+ border-color: var(--n-line);
240
+ color: var(--n-ink);
241
+ }
242
+
243
+ .lexical-form button:focus,
244
+ .lexical-form input:focus {
245
+ outline: 3px solid var(--n-accent-soft);
246
+ outline-offset: 2px;
247
+ }
248
+
249
+ .lexical-result {
250
+ display: grid;
251
+ align-content: start;
252
+ gap: 1rem;
253
+ background: var(--n-card);
254
+ }
255
+
256
+ .lexical-result-title {
257
+ margin: 0;
258
+ color: var(--n-muted);
259
+ font-size: 0.75rem;
260
+ letter-spacing: 0.08em;
261
+ text-transform: uppercase;
262
+ }
263
+
264
+ .lexical-status {
265
+ margin: 0;
266
+ border-left: 0.35rem solid var(--n-accent);
267
+ background: var(--n-accent-soft);
268
+ color: var(--n-accent-strong);
269
+ font-weight: 800;
270
+ padding: 0.8rem 1rem;
271
+ }
272
+
273
+ .lexical-status.at-target {
274
+ border-color: var(--n-good);
275
+ color: var(--n-good);
276
+ }
277
+
278
+ .lexical-status.buffer {
279
+ border-color: var(--n-water);
280
+ color: var(--n-water);
281
+ }
282
+
283
+ .lexical-headline,
284
+ .lexical-readout {
285
+ display: grid;
286
+ grid-template-columns: repeat(2, 1fr);
287
+ gap: 0.6rem;
288
+ }
289
+
290
+ .lexical-readout {
291
+ grid-template-columns: repeat(3, 1fr);
292
+ }
293
+
294
+ .lexical-metric {
295
+ display: grid;
296
+ gap: 0.25rem;
297
+ min-width: 0;
298
+ padding: 0.8rem;
299
+ border-top: 2px solid var(--n-line);
300
+ }
301
+
302
+ .lexical-metric-label {
303
+ color: var(--n-muted);
304
+ font-size: 0.7rem;
305
+ font-weight: 800;
306
+ letter-spacing: 0.05em;
307
+ text-transform: uppercase;
308
+ }
309
+
310
+ .lexical-metric-value {
311
+ overflow-wrap: anywhere;
312
+ color: var(--n-ink);
313
+ font-size: clamp(1rem, 2.4vw, 1.45rem);
314
+ font-weight: 850;
315
+ }
316
+
317
+ .lexical-headline .lexical-metric:first-child .lexical-metric-value {
318
+ color: var(--n-accent-strong);
319
+ font-size: clamp(1.8rem, 4vw, 3rem);
320
+ line-height: 1;
321
+ }
322
+
323
+ .lexical-scene-wrap {
324
+ padding: 0.8rem 0;
325
+ border-top: 1px solid var(--n-line);
326
+ border-bottom: 1px solid var(--n-line);
327
+ }
328
+
329
+ .lexical-scene-label {
330
+ display: block;
331
+ margin-bottom: 0.4rem;
332
+ color: var(--n-muted);
333
+ font-size: 0.75rem;
334
+ font-weight: 800;
335
+ letter-spacing: 0.06em;
336
+ text-transform: uppercase;
337
+ }
338
+
339
+ .lexical-atlas {
340
+ container-type: inline-size;
341
+ display: block;
342
+ width: 100%;
343
+ height: auto;
344
+ overflow: visible;
345
+ }
346
+
347
+ .atlas-river,
348
+ .atlas-bank {
349
+ fill: none;
350
+ stroke-linecap: round;
351
+ }
352
+
353
+ .atlas-river {
354
+ stroke: var(--n-water);
355
+ stroke-width: 18;
356
+ }
357
+
358
+ .atlas-bank {
359
+ stroke: var(--n-water-soft);
360
+ stroke-width: 4;
361
+ }
362
+
363
+ .atlas-guide {
364
+ stroke: var(--n-line);
365
+ stroke-dasharray: 3 7;
366
+ stroke-width: 1;
367
+ }
368
+
369
+ .atlas-checkpoint {
370
+ fill: var(--n-card);
371
+ stroke: var(--n-accent);
372
+ stroke-width: 3;
373
+ }
374
+
375
+ .atlas-current-marker {
376
+ fill: var(--n-accent);
377
+ stroke: var(--n-card);
378
+ stroke-width: 5;
379
+ }
380
+
381
+ .atlas-coverage,
382
+ .atlas-word-count,
383
+ .atlas-current-label {
384
+ fill: var(--n-ink);
385
+ font-size: 14px;
386
+ font-weight: 800;
387
+ }
388
+
389
+ @container (max-width: 760px) {
390
+ .atlas-word-count-intermediate {
391
+ display: none;
392
+ }
393
+ }
394
+
395
+ .atlas-word-count {
396
+ fill: var(--n-muted);
397
+ font-size: 12px;
398
+ font-weight: 600;
399
+ }
400
+
401
+ .atlas-current-label {
402
+ fill: var(--n-accent-strong);
403
+ font-size: 16px;
404
+ }
405
+
406
+ .section-seo .seo-title .prefix {
407
+ display: none;
408
+ }
409
+
410
+ .lexical-next,
411
+ .lexical-empty,
412
+ .lexical-error {
413
+ margin: 0;
414
+ color: var(--n-muted);
415
+ line-height: 1.5;
416
+ }
417
+
418
+ .lexical-next {
419
+ grid-column: 1 / -1;
420
+ padding: 0.75rem 0.8rem;
421
+ border: 1px solid var(--n-line);
422
+ background: var(--n-water-soft);
423
+ color: var(--n-ink);
424
+ }
425
+
426
+ .lexical-interpretation {
427
+ grid-column: 1 / -1;
428
+ display: grid;
429
+ gap: 0.35rem;
430
+ padding: 0.8rem;
431
+ border-left: 0.25rem solid var(--n-water);
432
+ background: var(--n-water-soft);
433
+ }
434
+
435
+ .lexical-interpretation-title {
436
+ color: var(--n-water);
437
+ font-size: 0.72rem;
438
+ font-weight: 800;
439
+ letter-spacing: 0.06em;
440
+ text-transform: uppercase;
441
+ }
442
+
443
+ .lexical-interpretation-copy {
444
+ color: var(--n-ink);
445
+ line-height: 1.45;
446
+ }
447
+
448
+ .lexical-empty {
449
+ display: grid;
450
+ justify-items: center;
451
+ gap: 0.8rem;
452
+ padding: 1rem 0;
453
+ text-align: center;
454
+ }
455
+
456
+ .empty-atlas-glyph {
457
+ width: 7rem;
458
+ height: auto;
459
+ }
460
+
461
+ .empty-page {
462
+ fill: var(--n-accent-soft);
463
+ stroke: var(--n-accent);
464
+ stroke-width: 3;
465
+ }
466
+
467
+ .empty-line {
468
+ stroke: var(--n-accent-strong);
469
+ stroke-linecap: round;
470
+ stroke-width: 3;
471
+ }
472
+
473
+ @media (max-width: 760px) {
474
+ .lexical-coverage {
475
+ grid-template-columns: 1fr;
476
+ }
477
+
478
+ .lexical-form {
479
+ border-right: 0;
480
+ border-bottom: 1px solid var(--n-line);
481
+ }
482
+ }
483
+
484
+ @media (max-width: 430px) {
485
+ .lexical-form,
486
+ .lexical-result {
487
+ padding: 1rem;
488
+ }
489
+
490
+ .preset-grid button {
491
+ font-size: 0.75rem;
492
+ }
493
+
494
+ .lexical-readout {
495
+ grid-template-columns: 1fr;
496
+ }
497
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateLexicalCoverage, estimateCoverage, validateInputs, VOCABULARY_PRESETS } from './logic';
3
+
4
+ describe('calculateLexicalCoverage', () => {
5
+ it('matches the 2,000 word and 80% conversation reference case', () => {
6
+ const result = calculateLexicalCoverage({ contentType: 'conversation', knownWords: 0, targetCoverage: 80 });
7
+ expect(result.ok).toBe(true);
8
+ if (!result.ok) return;
9
+ expect(result.plan.recommendedWords).toBe(2000);
10
+ expect(result.plan.estimatedCoverage).toBe(0);
11
+ expect(result.plan.gapWords).toBe(2000);
12
+ });
13
+
14
+ it('matches the 8,000 word novel and business planning band', () => {
15
+ const novel = calculateLexicalCoverage({ contentType: 'novels', knownWords: 8000, targetCoverage: 95 });
16
+ const business = calculateLexicalCoverage({ contentType: 'business', knownWords: 5000, targetCoverage: 95 });
17
+ expect(novel.ok && novel.plan.recommendedWords).toBe(8000);
18
+ expect(business.ok && business.plan.recommendedWords).toBe(8000);
19
+ expect(business.ok && business.plan.gapWords).toBe(3000);
20
+ });
21
+
22
+ it('interpolates coverage between documented checkpoints and caps the model', () => {
23
+ const preset = VOCABULARY_PRESETS.conversation;
24
+ expect(estimateCoverage(preset, 2500)).toBe(85);
25
+ expect(estimateCoverage(preset, 10000)).toBe(98);
26
+ });
27
+ });
28
+
29
+ describe('validateInputs', () => {
30
+ it('rejects unsupported content, word counts, and targets', () => {
31
+ expect(validateInputs({ contentType: 'unknown' as never, knownWords: 1000, targetCoverage: 80 })).toContain('content type');
32
+ expect(validateInputs({ contentType: 'conversation', knownWords: -1, targetCoverage: 80 })).toContain('between 0');
33
+ expect(validateInputs({ contentType: 'conversation', knownWords: 1000, targetCoverage: 85 as never })).toContain('coverage target');
34
+ });
35
+ });
@@ -0,0 +1,76 @@
1
+ import type { ContentType, CoverageTarget, LexicalCoverageInputs, LexicalCoverageResult, VocabularyPreset } from './types';
2
+
3
+ export const COVERAGE_TARGETS: CoverageTarget[] = [80, 90, 95, 98];
4
+
5
+ export const VOCABULARY_PRESETS: Record<ContentType, VocabularyPreset> = {
6
+ conversation: { id: 'conversation', wordsByCoverage: { 80: 2000, 90: 3000, 95: 4500, 98: 6500 } },
7
+ travel: { id: 'travel', wordsByCoverage: { 80: 2500, 90: 3500, 95: 5000, 98: 7000 } },
8
+ media: { id: 'media', wordsByCoverage: { 80: 3000, 90: 4500, 95: 6000, 98: 8000 } },
9
+ generalReading: { id: 'generalReading', wordsByCoverage: { 80: 4000, 90: 5500, 95: 7000, 98: 9000 } },
10
+ novels: { id: 'novels', wordsByCoverage: { 80: 5000, 90: 6500, 95: 8000, 98: 9000 } },
11
+ business: { id: 'business', wordsByCoverage: { 80: 5000, 90: 6500, 95: 8000, 98: 10000 } },
12
+ academic: { id: 'academic', wordsByCoverage: { 80: 6000, 90: 8000, 95: 10000, 98: 12000 } },
13
+ };
14
+
15
+ export function calculateLexicalCoverage(inputs: LexicalCoverageInputs): LexicalCoverageResult {
16
+ const error = validateInputs(inputs);
17
+ if (error) return { ok: false, error };
18
+ const preset = VOCABULARY_PRESETS[inputs.contentType];
19
+ const recommendedWords = preset.wordsByCoverage[inputs.targetCoverage];
20
+ const estimatedCoverage = estimateCoverage(preset, inputs.knownWords);
21
+ const checkpoints = COVERAGE_TARGETS.map((coverage) => ({ coverage, words: preset.wordsByCoverage[coverage] }));
22
+ const nextCheckpoint = checkpoints.find((checkpoint) => checkpoint.words > inputs.knownWords) ?? null;
23
+ return {
24
+ ok: true,
25
+ plan: {
26
+ inputs,
27
+ recommendedWords,
28
+ knownWords: inputs.knownWords,
29
+ estimatedCoverage,
30
+ unknownPerHundred: round(100 - estimatedCoverage, 1),
31
+ gapWords: Math.max(0, recommendedWords - inputs.knownWords),
32
+ status: getStatus(inputs.knownWords, recommendedWords, preset.wordsByCoverage[98]),
33
+ checkpoints,
34
+ nextCheckpoint,
35
+ },
36
+ };
37
+ }
38
+
39
+ export function validateInputs(inputs: LexicalCoverageInputs): string | null {
40
+ if (!Object.hasOwn(VOCABULARY_PRESETS, inputs.contentType)) return 'Choose a supported content type.';
41
+ if (!Number.isFinite(inputs.knownWords) || inputs.knownWords < 0 || inputs.knownWords > 50000) return 'Known vocabulary must be between 0 and 50,000 words.';
42
+ if (!COVERAGE_TARGETS.includes(inputs.targetCoverage)) return 'Choose a coverage target between 80% and 98%.';
43
+ return null;
44
+ }
45
+
46
+ export function estimateCoverage(preset: VocabularyPreset, knownWords: number): number {
47
+ if (knownWords <= 0) return 0;
48
+ const first = COVERAGE_TARGETS[0]!;
49
+ const firstWords = preset.wordsByCoverage[first];
50
+ if (knownWords < firstWords) return round((knownWords / firstWords) * first, 1);
51
+ const last = COVERAGE_TARGETS.at(-1)!;
52
+ if (knownWords >= preset.wordsByCoverage[last]) return 98;
53
+ for (let index = 0; index < COVERAGE_TARGETS.length - 1; index += 1) {
54
+ const lowCoverage = COVERAGE_TARGETS[index]!;
55
+ const highCoverage = COVERAGE_TARGETS[index + 1]!;
56
+ const lowWords = preset.wordsByCoverage[lowCoverage];
57
+ const highWords = preset.wordsByCoverage[highCoverage];
58
+ if (knownWords < highWords) return round(interpolate({ value: knownWords, lowValue: lowWords, highValue: highWords, lowResult: lowCoverage, highResult: highCoverage }), 1);
59
+ }
60
+ return 80;
61
+ }
62
+
63
+ function getStatus(knownWords: number, targetWords: number, bufferWords: number): 'below-target' | 'at-target' | 'buffer' {
64
+ if (knownWords < targetWords) return 'below-target';
65
+ if (knownWords < bufferWords) return 'at-target';
66
+ return 'buffer';
67
+ }
68
+
69
+ function interpolate(values: { value: number; lowValue: number; highValue: number; lowResult: number; highResult: number }): number {
70
+ return values.lowResult + ((values.value - values.lowValue) / (values.highValue - values.lowValue)) * (values.highResult - values.lowResult);
71
+ }
72
+
73
+ function round(value: number, decimals: number): number {
74
+ const factor = 10 ** decimals;
75
+ return Math.round(value * factor) / factor;
76
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { lexicalCoverageVocabularyCalculator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ const locale = (Astro.props.locale ?? 'en') as KnownLocale;
7
+ const loader = lexicalCoverageVocabularyCalculator.i18n[locale] ?? lexicalCoverageVocabularyCalculator.i18n.en;
8
+ const content = await loader?.();
9
+ ---
10
+
11
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,24 @@
1
+ import type { LexicalCoverageInputs } from './types';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-language-lexical-coverage-vocabulary-calculator';
4
+
5
+ export function readLexicalCoverageInputs(): Partial<LexicalCoverageInputs> | null {
6
+ try {
7
+ const stored = window.localStorage.getItem(STORAGE_KEY);
8
+ return stored ? JSON.parse(stored) as Partial<LexicalCoverageInputs> : null;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export function saveLexicalCoverageInputs(inputs: LexicalCoverageInputs): void {
15
+ try {
16
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(inputs));
17
+ } catch {}
18
+ }
19
+
20
+ export function clearLexicalCoverageInputs(): void {
21
+ try {
22
+ window.localStorage.removeItem(STORAGE_KEY);
23
+ } catch {}
24
+ }