@kitkat-vn/kitcode 0.2.0-staging.9 → 0.3.0-staging.15
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.
- package/README.md +45 -28
- package/bin/kitcode.mjs +9 -1
- package/package.json +2 -2
- package/src/api.mjs +105 -3
- package/src/break-progress.mjs +94 -9
- package/src/claim-bundle.mjs +18 -5
- package/src/companion-window.mjs +7 -3
- package/src/cors.mjs +3 -1
- package/src/cycle-receipt.mjs +97 -0
- package/src/fonts/jetbrains-mono/OFL.txt +93 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-latin-700-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-400-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-500-normal.woff2 +0 -0
- package/src/fonts/jetbrains-mono/jetbrains-mono-vietnamese-700-normal.woff2 +0 -0
- package/src/hook-prompt.mjs +15 -3
- package/src/integration-spec.mjs +175 -48
- package/src/milestone-record.mjs +214 -0
- package/src/milestones-reached.mjs +163 -64
- package/src/next-break-reminder.mjs +4 -3
- package/src/onboarding-electron.mjs +12 -11
- package/src/onboarding-platform.mjs +5 -2
- package/src/onboarding-window.mjs +94 -63
- package/src/reward.mjs +214 -63
- package/src/runtime.mjs +48 -4
- package/src/welcome-words.mjs +115 -0
- package/src/window-page.mjs +16 -2
package/src/reward.mjs
CHANGED
|
@@ -3,22 +3,27 @@ import {equalsLedgerFrom, storedEqualsLedger} from './equals-ledger.mjs';
|
|
|
3
3
|
import {focusTimeFrom} from './focus-clock.mjs';
|
|
4
4
|
import {nextBreakReminder} from './next-break-reminder.mjs';
|
|
5
5
|
import {KITCODE_DISPLAY_MILESTONES, KITCODE_REWARD_TIERS} from './integration-spec.mjs';
|
|
6
|
+
import {readMilestoneRecord} from './milestone-record.mjs';
|
|
6
7
|
import {loadState, updateState} from './store.mjs';
|
|
7
8
|
|
|
8
|
-
// The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
|
|
9
|
-
// §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
|
|
10
|
-
// rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
|
|
11
|
-
// file, unread, as the removed requiredEquals does.
|
|
12
|
-
export const REWARD_SECONDS = 3600;
|
|
13
9
|
export const REWARD_TIERS = KITCODE_REWARD_TIERS;
|
|
14
10
|
export const CAMPAIGN_MILESTONES = KITCODE_DISPLAY_MILESTONES;
|
|
15
11
|
export const REWARD_MILESTONES = [
|
|
16
12
|
...REWARD_TIERS.map((tier) => ({...tier, rewardBacked: true})),
|
|
17
13
|
...CAMPAIGN_MILESTONES.map((milestone) => ({...milestone, rewardBacked: false})),
|
|
18
14
|
];
|
|
15
|
+
export const MILESTONE_PERCENTS = REWARD_MILESTONES.map((milestone) => milestone.percent);
|
|
16
|
+
// The focus time the 100 % milestone needs, fixed (operator decision Q5, 2026-09-25; docs/reward-rules.md
|
|
17
|
+
// §3). Until then it was a setting, down to 1 s: --reward-seconds, KITCODE_REWARD_SECONDS, and
|
|
18
|
+
// rewardSettings.requiredSeconds in state.json. A requiredSeconds an older KitCode stored stays in the
|
|
19
|
+
// file, unread, as the removed requiredEquals does. Since 2026-10-20 each milestone names its own time in the table
|
|
20
|
+
// (integration-spec.mjs), where it was this × its percent: the same numbers in the tester table, which held the operator's
|
|
21
|
+
// from 2026-10-21 to 2026-10-22, 100 %'s 8 h, and since then its own again, 100 %'s 3600 s.
|
|
22
|
+
const LAST_MILESTONE = REWARD_MILESTONES[REWARD_MILESTONES.length - 1];
|
|
23
|
+
export const REWARD_SECONDS = LAST_MILESTONE.requiredSeconds;
|
|
19
24
|
// The = the last milestone needs. The = targets are the table's; no setting changes them (the
|
|
20
25
|
// --reward-equals setting, which gated nothing, was removed on 2026-09-24).
|
|
21
|
-
const FINAL_EQUALS =
|
|
26
|
+
const FINAL_EQUALS = LAST_MILESTONE.requiredEquals;
|
|
22
27
|
|
|
23
28
|
const VALID_TIER_PERCENTS = new Set(REWARD_TIERS.map((tier) => tier.percent));
|
|
24
29
|
|
|
@@ -40,6 +45,27 @@ export function normalizeTierPercent(value) {
|
|
|
40
45
|
return percent;
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
function announcementsOf(value) {
|
|
49
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
50
|
+
? Object.fromEntries(Object.entries(value).filter(([source, at]) => normalizeSource(source) && typeof at === 'string'))
|
|
51
|
+
: {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2) a tier is announced once per agent in each cycle: cycle 1's
|
|
55
|
+
// announcements are `announced_at`, as they always were, and a later cycle's are kept by its number, `announced_in_cycles`,
|
|
56
|
+
// {"2": {"claude": "2026-…"}}, a field written only once a later cycle has one, so that a file with none is byte for byte
|
|
57
|
+
// what it was, and a KitCode from before reads cycle 1's as ever.
|
|
58
|
+
function cycleAnnouncementsOf(value) {
|
|
59
|
+
if (!isObject(value)) {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Object.fromEntries(Object.entries(value)
|
|
64
|
+
.filter(([cycle]) => /^[1-9][0-9]*$/u.test(cycle) && Number(cycle) >= 2)
|
|
65
|
+
.map(([cycle, announced]) => [cycle, announcementsOf(announced)])
|
|
66
|
+
.filter(([, announced]) => Object.keys(announced).length > 0));
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
function normalizeTierRecord(entry) {
|
|
44
70
|
const percent = normalizeTierPercent(
|
|
45
71
|
typeof entry === 'object' && entry !== null ? entry.percent : entry,
|
|
@@ -49,20 +75,21 @@ function normalizeTierRecord(entry) {
|
|
|
49
75
|
return null;
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
const
|
|
53
|
-
? Object.fromEntries(
|
|
54
|
-
Object.entries(entry.announced_at)
|
|
55
|
-
.filter(([source, value]) => normalizeSource(source) && typeof value === 'string'),
|
|
56
|
-
)
|
|
57
|
-
: {};
|
|
78
|
+
const byCycle = cycleAnnouncementsOf(entry?.announced_in_cycles);
|
|
58
79
|
|
|
59
80
|
return {
|
|
60
81
|
percent,
|
|
61
82
|
redeemed_at: typeof entry?.redeemed_at === 'string' ? entry.redeemed_at : null,
|
|
62
|
-
announced_at:
|
|
83
|
+
announced_at: announcementsOf(entry?.announced_at),
|
|
84
|
+
...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
|
|
63
85
|
};
|
|
64
86
|
}
|
|
65
87
|
|
|
88
|
+
// Whether `record`, a tier's record, normalized, holds its announcement to `source` in `cycle`.
|
|
89
|
+
function announcedIn(record, source, cycle) {
|
|
90
|
+
return cycle > 1 ? Boolean(record?.announced_in_cycles?.[String(cycle)]?.[source]) : Boolean(record?.announced_at?.[source]);
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
export function normalizeEarnedTiers(entries) {
|
|
67
94
|
const records = new Map();
|
|
68
95
|
|
|
@@ -74,6 +101,12 @@ export function normalizeEarnedTiers(entries) {
|
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
const existing = records.get(record.percent);
|
|
104
|
+
const byCycle = {...(existing?.announced_in_cycles ?? {})};
|
|
105
|
+
|
|
106
|
+
for (const [cycle, announced] of Object.entries(record.announced_in_cycles ?? {})) {
|
|
107
|
+
byCycle[cycle] = {...(byCycle[cycle] ?? {}), ...announced};
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
records.set(record.percent, {
|
|
78
111
|
percent: record.percent,
|
|
79
112
|
redeemed_at: existing?.redeemed_at ?? record.redeemed_at,
|
|
@@ -81,6 +114,7 @@ export function normalizeEarnedTiers(entries) {
|
|
|
81
114
|
...(existing?.announced_at ?? {}),
|
|
82
115
|
...record.announced_at,
|
|
83
116
|
},
|
|
117
|
+
...(Object.keys(byCycle).length > 0 ? {announced_in_cycles: byCycle} : {}),
|
|
84
118
|
});
|
|
85
119
|
}
|
|
86
120
|
|
|
@@ -125,65 +159,176 @@ function updateTierRecord(ledger, percent, change) {
|
|
|
125
159
|
});
|
|
126
160
|
}
|
|
127
161
|
|
|
128
|
-
function
|
|
129
|
-
return
|
|
162
|
+
function reachesTargets(earned, milestone) {
|
|
163
|
+
return earned.seconds >= milestone.requiredSeconds && earned.equals >= milestone.requiredEquals;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The milestones as they stacked until 2026-10-20 (docs/reward-rules.md §2): each reached when the totals since install
|
|
167
|
+
// meet its own targets, with every milestone below the highest one reached, or whose code an earlier KitCode recorded as
|
|
168
|
+
// shown (E8). A record that is not what KitCode writes is read so, and nothing is written over it (decideMilestones).
|
|
169
|
+
function stackedReached(totals, redeemed) {
|
|
170
|
+
const highest = Math.max(0, ...REWARD_MILESTONES
|
|
171
|
+
.filter((milestone) => reachesTargets(totals, milestone) || redeemed.has(milestone.percent))
|
|
172
|
+
.map((milestone) => milestone.percent));
|
|
173
|
+
|
|
174
|
+
return new Set(MILESTONE_PERCENTS.filter((percent) => percent <= highest));
|
|
130
175
|
}
|
|
131
176
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
177
|
+
// Which milestones are reached, and where each counts from (docs/reward-rules.md §2; the plan of 2026-10-20, decisions 1
|
|
178
|
+
// to 3). Each counts its own focus time and = from the moment the milestone before it was reached, the first from install,
|
|
179
|
+
// and is reached when what was earned since meets both its targets: nothing earned before that moment counts toward it,
|
|
180
|
+
// neither the time earned while waiting for = nor the = while waiting for time. So at most one is reached at a time.
|
|
181
|
+
//
|
|
182
|
+
// `totals` are the focus seconds, floored, and the = counted since install; `record` the milestone record as state.json
|
|
183
|
+
// holds it; `redeemed` the tiers an earlier KitCode recorded a code shown for. A milestone the record holds is reached,
|
|
184
|
+
// whatever the totals say now, and so is every one below it: a reached milestone stays reached. One that is not held
|
|
185
|
+
// is reached once the totals since its start meet its targets, its moment the moment this is found: the record's next
|
|
186
|
+
// write records it, with these totals.
|
|
187
|
+
//
|
|
188
|
+
// Decision 2, people who reached milestones before this rule: a milestone reached with no totals known, an earlier
|
|
189
|
+
// KitCode's entry or one reached before the record began, keeps its place, and the one after it counts from the record's
|
|
190
|
+
// freshSince, the totals when this KitCode first wrote such a record; until that write, from the totals now. A file with
|
|
191
|
+
// no record yet is read as the first write will record it: the milestones its totals reached as they stacked, reached
|
|
192
|
+
// before the record began, and the next counting from now.
|
|
193
|
+
//
|
|
194
|
+
// Since 2026-10-22 (rewards/3-repeat-vouchers, phase 2; the plan of 2026-10-21, decision 1) the milestones count in the cycle
|
|
195
|
+
// the record is in (milestone-record.mjs): cycle 1, as above, until the tracker takes a receipt the claim backend signed for
|
|
196
|
+
// a voucher; then the next, whose milestones all count fresh from the totals at that moment, the first from there where cycle
|
|
197
|
+
// 1's counts from install. Nothing of an earlier cycle counts toward a later one, and nothing an earlier KitCode recorded as a
|
|
198
|
+
// code shown (`redeemed`) does either: that is cycle 1's.
|
|
199
|
+
//
|
|
200
|
+
// Returns {stacked, entries, cycle}: `stacked` true for a record that is not what KitCode writes, read as the milestones
|
|
201
|
+
// stacked; otherwise each entry {percent, reached, recorded, countedFrom, end}, `countedFrom` the totals the milestone counts
|
|
202
|
+
// from, or null for one that has not begun, or whose start is not known; `end` the totals it was reached at, null when not
|
|
203
|
+
// known; and `cycle` {number, startedAt, start}, the cycle counted in, when it began (null for cycle 1) and its start totals.
|
|
204
|
+
export function decideMilestones({totals, record, redeemed = new Set()}) {
|
|
205
|
+
const read = readMilestoneRecord(record, MILESTONE_PERCENTS);
|
|
206
|
+
|
|
207
|
+
if (read.state === 'damaged') {
|
|
208
|
+
return {stacked: true, reached: stackedReached(totals, redeemed)};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const current = read.state === 'none' ? null : read.current;
|
|
212
|
+
const later = current !== null && current.cycle > 1;
|
|
213
|
+
const before = read.state === 'none'
|
|
214
|
+
? new Map([...stackedReached(totals, redeemed)].map((percent) => [percent, {kind: 'before-recording', end: null}]))
|
|
215
|
+
: current.entries;
|
|
216
|
+
const freshSince = read.state === 'none' ? null : current.freshSince;
|
|
217
|
+
const shown = later ? [] : [...redeemed].filter((percent) => MILESTONE_PERCENTS.includes(percent));
|
|
218
|
+
const highest = Math.max(0, ...before.keys(), ...shown);
|
|
219
|
+
const entries = [];
|
|
220
|
+
// Where the next milestone counts from: install for the first of cycle 1, a later cycle's start for its first; null once one
|
|
221
|
+
// has not been reached.
|
|
222
|
+
let start = later ? {...current.start} : {seconds: 0, equals: 0};
|
|
223
|
+
let startKnown = true;
|
|
224
|
+
|
|
225
|
+
for (const milestone of REWARD_MILESTONES) {
|
|
226
|
+
const entry = before.get(milestone.percent);
|
|
227
|
+
|
|
228
|
+
if (start === null) {
|
|
229
|
+
entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: null, end: null});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (milestone.percent <= highest) {
|
|
234
|
+
const end = entry?.end ?? null;
|
|
235
|
+
entries.push({percent: milestone.percent, reached: true, recorded: entry !== undefined, countedFrom: startKnown ? start : null, end});
|
|
236
|
+
start = end ?? (freshSince ? {seconds: freshSince.seconds, equals: freshSince.equals} : {...totals});
|
|
237
|
+
startKnown = end !== null || freshSince !== null;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const earned = {seconds: Math.max(0, totals.seconds - start.seconds), equals: Math.max(0, totals.equals - start.equals)};
|
|
242
|
+
|
|
243
|
+
if (reachesTargets(earned, milestone)) {
|
|
244
|
+
entries.push({percent: milestone.percent, reached: true, recorded: false, countedFrom: start, end: {...totals}});
|
|
245
|
+
start = {...totals};
|
|
246
|
+
startKnown = true;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
entries.push({percent: milestone.percent, reached: false, recorded: false, countedFrom: start, end: null});
|
|
251
|
+
start = null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
stacked: false,
|
|
256
|
+
entries,
|
|
257
|
+
cycle: later ? {number: current.cycle, startedAt: current.startedAt, start: {...current.start}} : {number: 1, startedAt: null, start: {seconds: 0, equals: 0}},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The focus time still needed to reach 100 %, each milestone not reached counted fresh, the current one from its start.
|
|
262
|
+
function timeLeft(totals, milestones) {
|
|
263
|
+
return milestones.reduce((sum, milestone) => {
|
|
264
|
+
if (milestone.unlocked) {
|
|
265
|
+
return sum;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const from = milestone.countedFrom?.seconds;
|
|
269
|
+
return sum + Math.max(0, milestone.requiredSeconds - (from === undefined || from === null ? 0 : Math.max(0, totals.seconds - from)));
|
|
270
|
+
}, 0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The reward summary for `earnedSeconds` of focus time and `totalEquals` counted, against the milestone table
|
|
274
|
+
// (integration-spec.mjs), and the milestone record (`record`, state.json's milestonesReached; decideMilestones). It takes
|
|
275
|
+
// no target: a `settings` a caller still passes, as older code and tests do, is not read.
|
|
276
|
+
//
|
|
277
|
+
// Since 2026-10-20 each milestone of the summary also says where it counts from, `countedFrom`, {seconds, equals}, or null
|
|
278
|
+
// for one that has not begun: what counts toward it is what was earned since (break-progress.mjs). A record that is not
|
|
279
|
+
// what KitCode writes gives the summary as it was before: no `countedFrom`, every milestone counted from install.
|
|
280
|
+
//
|
|
281
|
+
// Since 2026-10-22 the summary says the cycle its milestones count in, `cycle`, 1 until the tracker takes a receipt for a
|
|
282
|
+
// voucher (decideMilestones), and `cycleStartedAt`, when a later one began, null for cycle 1; both null for a record that is
|
|
283
|
+
// not what KitCode writes. Its milestones and tiers are that cycle's: in a later one, none is `redeemed`, a code an earlier
|
|
284
|
+
// KitCode recorded as shown being cycle 1's.
|
|
285
|
+
export function buildRewardSummary({earnedSeconds, totalEquals, ledger, record}) {
|
|
137
286
|
const records = tierRecordMap(ledger);
|
|
138
287
|
const normalizedEarnedSeconds = Math.floor(Math.max(0, Number(earnedSeconds) || 0));
|
|
139
288
|
const normalizedTotalEquals = Math.max(0, Number(totalEquals) || 0);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
147
|
-
const
|
|
289
|
+
const totals = {seconds: normalizedEarnedSeconds, equals: normalizedTotalEquals};
|
|
290
|
+
const redeemed = new Set(REWARD_TIERS.filter((tier) => records.get(tier.percent)?.redeemed_at).map((tier) => tier.percent));
|
|
291
|
+
const decided = decideMilestones({totals, record, redeemed});
|
|
292
|
+
const laterCycle = !decided.stacked && decided.cycle.number > 1;
|
|
293
|
+
const milestones = REWARD_MILESTONES.map((milestone, index) => {
|
|
294
|
+
const record = records.get(milestone.percent);
|
|
295
|
+
const isRedeemed = milestone.rewardBacked && Boolean(record?.redeemed_at) && !laterCycle;
|
|
296
|
+
const unlocked = decided.stacked ? decided.reached.has(milestone.percent) : decided.entries[index].reached;
|
|
148
297
|
|
|
149
298
|
return {
|
|
150
|
-
percent:
|
|
151
|
-
requiredEquals:
|
|
152
|
-
requiredSeconds:
|
|
299
|
+
percent: milestone.percent,
|
|
300
|
+
requiredEquals: milestone.requiredEquals,
|
|
301
|
+
requiredSeconds: milestone.requiredSeconds,
|
|
302
|
+
rewardBacked: milestone.rewardBacked,
|
|
303
|
+
displayOnly: !milestone.rewardBacked,
|
|
153
304
|
unlocked,
|
|
154
|
-
redeemed,
|
|
155
|
-
redeemedAt: record
|
|
156
|
-
status:
|
|
305
|
+
redeemed: isRedeemed,
|
|
306
|
+
redeemedAt: isRedeemed ? record.redeemed_at : null,
|
|
307
|
+
status: isRedeemed ? 'redeemed' : unlocked ? 'ready' : 'locked',
|
|
308
|
+
...(decided.stacked ? {} : {countedFrom: decided.entries[index].countedFrom}),
|
|
157
309
|
};
|
|
158
310
|
});
|
|
159
|
-
const
|
|
311
|
+
const tierPercents = new Set(REWARD_TIERS.map((tier) => tier.percent));
|
|
312
|
+
const tiers = milestones.filter((milestone) => tierPercents.has(milestone.percent)).map((milestone) => ({
|
|
313
|
+
percent: milestone.percent,
|
|
314
|
+
requiredEquals: milestone.requiredEquals,
|
|
315
|
+
requiredSeconds: milestone.requiredSeconds,
|
|
316
|
+
unlocked: milestone.unlocked,
|
|
317
|
+
redeemed: milestone.redeemed,
|
|
318
|
+
redeemedAt: milestone.redeemedAt,
|
|
319
|
+
status: milestone.status,
|
|
320
|
+
}));
|
|
160
321
|
|
|
161
322
|
const summary = {
|
|
162
|
-
requiredSeconds,
|
|
323
|
+
requiredSeconds: REWARD_SECONDS,
|
|
163
324
|
requiredEquals: FINAL_EQUALS,
|
|
164
325
|
earnedSeconds: normalizedEarnedSeconds,
|
|
165
326
|
totalEquals: normalizedTotalEquals,
|
|
166
|
-
timeLeftSeconds:
|
|
327
|
+
timeLeftSeconds: timeLeft(totals, milestones),
|
|
328
|
+
cycle: decided.stacked ? null : decided.cycle.number,
|
|
329
|
+
cycleStartedAt: decided.stacked ? null : decided.cycle.startedAt,
|
|
167
330
|
tiers,
|
|
168
|
-
milestones
|
|
169
|
-
const tier = tierMap.get(milestone.percent);
|
|
170
|
-
const requiredTimeSeconds = tier?.requiredSeconds ?? milestoneTimeTarget(requiredSeconds, milestone.percent);
|
|
171
|
-
const timeReached = normalizedEarnedSeconds >= requiredTimeSeconds;
|
|
172
|
-
const equalsReached = normalizedTotalEquals >= milestone.requiredEquals;
|
|
173
|
-
const unlocked = timeReached && equalsReached;
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
percent: milestone.percent,
|
|
177
|
-
requiredEquals: milestone.requiredEquals,
|
|
178
|
-
requiredSeconds: requiredTimeSeconds,
|
|
179
|
-
rewardBacked: milestone.rewardBacked,
|
|
180
|
-
displayOnly: !milestone.rewardBacked,
|
|
181
|
-
unlocked,
|
|
182
|
-
redeemed: tier?.redeemed ?? false,
|
|
183
|
-
redeemedAt: tier?.redeemedAt ?? null,
|
|
184
|
-
status: tier?.status ?? (unlocked ? 'ready' : 'locked'),
|
|
185
|
-
};
|
|
186
|
-
}),
|
|
331
|
+
milestones,
|
|
187
332
|
};
|
|
188
333
|
|
|
189
334
|
// The one progress number the CLI, the Mini and the dashboard all show (docs/reward-rules.md
|
|
@@ -203,6 +348,7 @@ function rewardSummaryFrom(state) {
|
|
|
203
348
|
earnedSeconds,
|
|
204
349
|
totalEquals: ledger.total_equals,
|
|
205
350
|
ledger,
|
|
351
|
+
record: state.milestonesReached,
|
|
206
352
|
});
|
|
207
353
|
}
|
|
208
354
|
|
|
@@ -227,6 +373,7 @@ function saveTierRecords(changes) {
|
|
|
227
373
|
// code an earlier version recorded as shown keeps its redeemed_at, and so its status, `redeemed`: it was reached, and
|
|
228
374
|
// the record says so (docs/reward-rules.md §7).
|
|
229
375
|
|
|
376
|
+
// Each tier given is announced to `source` in its cycle, `tier.cycle`, 1 when it names none (getReadyUnannouncedTiers).
|
|
230
377
|
export function markTiersAnnounced(source, tiers) {
|
|
231
378
|
const normalizedSource = normalizeSource(source);
|
|
232
379
|
|
|
@@ -245,8 +392,12 @@ export function markTiersAnnounced(source, tiers) {
|
|
|
245
392
|
continue;
|
|
246
393
|
}
|
|
247
394
|
|
|
248
|
-
|
|
249
|
-
|
|
395
|
+
const cycle = Number.isSafeInteger(tier.cycle) && tier.cycle > 1 ? tier.cycle : 1;
|
|
396
|
+
|
|
397
|
+
changes.push([percent, (record) => (cycle === 1
|
|
398
|
+
? {...record, announced_at: {...record.announced_at, [normalizedSource]: now}}
|
|
399
|
+
: {...record, announced_in_cycles: {...record.announced_in_cycles, [cycle]: {...record.announced_in_cycles?.[cycle], [normalizedSource]: now}}})]);
|
|
400
|
+
announced.push({...tier, cycle, announcedAt: now});
|
|
250
401
|
}
|
|
251
402
|
|
|
252
403
|
if (changes.length > 0) {
|
|
@@ -265,10 +416,10 @@ export function getReadyUnannouncedTiers(source) {
|
|
|
265
416
|
|
|
266
417
|
const state = loadState();
|
|
267
418
|
const records = tierRecordMap(equalsLedgerFrom(state));
|
|
419
|
+
const summary = rewardSummaryFrom(state);
|
|
420
|
+
const cycle = summary.cycle ?? 1;
|
|
268
421
|
|
|
269
|
-
return
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return tier.status === 'ready' && !record?.announced_at?.[normalizedSource];
|
|
273
|
-
});
|
|
422
|
+
return summary.tiers
|
|
423
|
+
.filter((tier) => tier.status === 'ready' && !announcedIn(records.get(tier.percent), normalizedSource, cycle))
|
|
424
|
+
.map((tier) => ({...tier, cycle}));
|
|
274
425
|
}
|
package/src/runtime.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
startedFocusTime,
|
|
44
44
|
storedFocusTime,
|
|
45
45
|
} from './focus-clock.mjs';
|
|
46
|
-
import {noteMilestonesReached} from './milestones-reached.mjs';
|
|
46
|
+
import {beginNextCycle, noteMilestonesReached} from './milestones-reached.mjs';
|
|
47
47
|
import {buildRewardSummary} from './reward.mjs';
|
|
48
48
|
import {createScanner} from './scanner.mjs';
|
|
49
49
|
|
|
@@ -452,7 +452,10 @@ export function prepareState({rules = RULES, to = RULES_VERSION} = {}) {
|
|
|
452
452
|
// earns nothing (§5.2, §5.7); a read that is a baseline, because code can have arrived through git,
|
|
453
453
|
// earns nothing at all (§5.8). Returns null when the project is not in state.json, or when a partial
|
|
454
454
|
// read cannot make its first snapshot.
|
|
455
|
-
|
|
455
|
+
//
|
|
456
|
+
// `focusSeconds` is the tracker's own clock, when it folds: ahead of the file's by up to one save, and the moment a milestone
|
|
457
|
+
// these = reach is recorded at with it (milestones-reached.mjs).
|
|
458
|
+
function foldScan(project, read, base, {lockTimeoutMs, focusSeconds} = {}) {
|
|
456
459
|
return withStateLock(() => {
|
|
457
460
|
// An older file is converted first, in this same update: after an upgrade, the first scan does it.
|
|
458
461
|
migrateState();
|
|
@@ -492,7 +495,7 @@ function foldScan(project, read, base, {lockTimeoutMs} = {}) {
|
|
|
492
495
|
stored.projects[project.id].changeBatchCount = (Number(current.changeBatchCount) || 0) + 1;
|
|
493
496
|
addSourceEqualsOnce(project, crypto.randomUUID(), result.equalsAdded);
|
|
494
497
|
// = credited can make a milestone ready: recorded in this same update (docs/reward-rules.md §6.1).
|
|
495
|
-
noteMilestonesReached(stored);
|
|
498
|
+
noteMilestonesReached(stored, undefined, {focusSeconds});
|
|
496
499
|
}
|
|
497
500
|
});
|
|
498
501
|
}
|
|
@@ -504,6 +507,7 @@ function foldScan(project, read, base, {lockTimeoutMs} = {}) {
|
|
|
504
507
|
...result,
|
|
505
508
|
changeBatchCount: Number(after.projects[project.id].changeBatchCount) || 0,
|
|
506
509
|
ledger: equalsLedgerFrom(after),
|
|
510
|
+
record: after.milestonesReached,
|
|
507
511
|
};
|
|
508
512
|
}, {lockTimeoutMs});
|
|
509
513
|
}
|
|
@@ -541,6 +545,8 @@ export function createRuntime(options) {
|
|
|
541
545
|
options,
|
|
542
546
|
state: normalizeState(stored),
|
|
543
547
|
ledger: equalsLedgerFrom(stored),
|
|
548
|
+
// The milestone record, which decides the milestones since 2026-10-20 (reward.mjs decideMilestones).
|
|
549
|
+
record: stored.milestonesReached,
|
|
544
550
|
focus: focusClockFrom(stored),
|
|
545
551
|
stateIdentity,
|
|
546
552
|
projectRuns: new Map(),
|
|
@@ -890,7 +896,7 @@ function applyRead(runtime, projectId, run, read, base, activityAt) {
|
|
|
890
896
|
let result;
|
|
891
897
|
|
|
892
898
|
try {
|
|
893
|
-
result = foldScan(project, read, base, {lockTimeoutMs: SCAN_LOCK_TIMEOUT_MS});
|
|
899
|
+
result = foldScan(project, read, base, {lockTimeoutMs: SCAN_LOCK_TIMEOUT_MS, focusSeconds: runtime.focus?.activeSeconds});
|
|
894
900
|
} catch (error) {
|
|
895
901
|
if (!(error instanceof StateLockError)) {
|
|
896
902
|
throw error;
|
|
@@ -908,6 +914,7 @@ function applyRead(runtime, projectId, run, read, base, activityAt) {
|
|
|
908
914
|
project.sourceSnapshot = result.snapshot;
|
|
909
915
|
project.changeBatchCount = result.changeBatchCount;
|
|
910
916
|
runtime.ledger = result.ledger;
|
|
917
|
+
runtime.record = result.record;
|
|
911
918
|
clearProblem(runtime, 'scan-failed', projectId);
|
|
912
919
|
clearProblem(runtime, 'project-missing', projectId);
|
|
913
920
|
const at = activityAt(result);
|
|
@@ -1150,6 +1157,7 @@ function reconcileProjects(runtime) {
|
|
|
1150
1157
|
const diskState = normalizeState(stored);
|
|
1151
1158
|
runtime.stateIdentity = identity;
|
|
1152
1159
|
runtime.ledger = equalsLedgerFrom(stored);
|
|
1160
|
+
runtime.record = stored.milestonesReached;
|
|
1153
1161
|
|
|
1154
1162
|
for (const [projectId, project] of Object.entries(runtime.state.projects)) {
|
|
1155
1163
|
if (!diskState.projects[projectId]) {
|
|
@@ -1184,6 +1192,8 @@ function reconcileProjects(runtime) {
|
|
|
1184
1192
|
// the file is left as it is. Since 2026-09-25 it also records a milestone its time has made ready
|
|
1185
1193
|
// (docs/reward-rules.md §6.1), within one save, 10 s, of it becoming ready.
|
|
1186
1194
|
export function persistRuntimeState(runtime, options) {
|
|
1195
|
+
let saved = null;
|
|
1196
|
+
|
|
1187
1197
|
updateState((stored) => {
|
|
1188
1198
|
if (!isObject(stored.projects)) {
|
|
1189
1199
|
return;
|
|
@@ -1215,7 +1225,40 @@ export function persistRuntimeState(runtime, options) {
|
|
|
1215
1225
|
|
|
1216
1226
|
stored.version = 4;
|
|
1217
1227
|
noteMilestonesReached(stored);
|
|
1228
|
+
saved = {record: stored.milestonesReached === undefined ? undefined : structuredClone(stored.milestonesReached)};
|
|
1218
1229
|
}, options);
|
|
1230
|
+
|
|
1231
|
+
// What the save recorded, for the tracker's own summary until its next read of the file. A copy: the file's object is
|
|
1232
|
+
// the store's.
|
|
1233
|
+
if (saved) {
|
|
1234
|
+
runtime.record = saved.record;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Begins the next cycle on `receipt`, one the tracker has read and checked (api.mjs, POST /api/claim/receipt; the plan of
|
|
1239
|
+
// 2026-10-21, decision 1), in one locked write with the tracker's own save: its counters first, as every save writes them,
|
|
1240
|
+
// so that the cycle starts at the totals the tracker shows, the focus time on its own clock among them; then the record
|
|
1241
|
+
// (milestones-reached.mjs, beginNextCycle), which says whether this receipt may begin one, and adds the cycle if it may.
|
|
1242
|
+
// Nothing else in the file changes, and the totals are never reduced. Returns beginNextCycle's outcome; throws the store's
|
|
1243
|
+
// StateLockError when the file stays locked, and its StateFileError when it cannot be read, as a save does, having written
|
|
1244
|
+
// nothing.
|
|
1245
|
+
export function startNextCycle(runtime, receipt, {lockTimeoutMs = PERIODIC_SAVE_LOCK_TIMEOUT_MS} = {}) {
|
|
1246
|
+
let outcome = null;
|
|
1247
|
+
let saved;
|
|
1248
|
+
|
|
1249
|
+
withStateLock(() => {
|
|
1250
|
+
tickActiveProjects(runtime);
|
|
1251
|
+
persistRuntimeState(runtime);
|
|
1252
|
+
outcome = updateState((stored) => {
|
|
1253
|
+
const result = beginNextCycle(stored, receipt, new Date(Date.now()));
|
|
1254
|
+
saved = stored.milestonesReached === undefined ? undefined : structuredClone(stored.milestonesReached);
|
|
1255
|
+
return result;
|
|
1256
|
+
});
|
|
1257
|
+
}, {lockTimeoutMs});
|
|
1258
|
+
|
|
1259
|
+
// What the write recorded, for the tracker's own summary at once: the new cycle's milestones count from now.
|
|
1260
|
+
runtime.record = saved;
|
|
1261
|
+
return outcome;
|
|
1219
1262
|
}
|
|
1220
1263
|
|
|
1221
1264
|
// Runs one of the tracker's periodic jobs. A busy lock skips it until the next tick. A state file
|
|
@@ -1253,6 +1296,7 @@ export function buildSummary(runtime) {
|
|
|
1253
1296
|
earnedSeconds: focus.activeSeconds,
|
|
1254
1297
|
totalEquals,
|
|
1255
1298
|
ledger,
|
|
1299
|
+
record: 'record' in runtime ? runtime.record : loadState().milestonesReached,
|
|
1256
1300
|
});
|
|
1257
1301
|
|
|
1258
1302
|
return {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Every word Welcome shows, in one place: the client's Vietnamese, word for word as the deck has it, and the operator's
|
|
2
|
+
// approved drafts where the deck does not reach (docs/plans/2026-10-22-vietnamese-setup-and-welcome-strings.md; each line
|
|
3
|
+
// names its row there, a number for the deck's, an id for a draft's). "just make sure the translation stay truthful to the
|
|
4
|
+
// deck": the deck's own spelling stays, "Kitcode" in row 56 included. A "\n" is the deck's line break; "{N}" is a count and
|
|
5
|
+
// "{name}" a folder's name, which the page fills in. Everything here is NFC, and every character is in the fonts Welcome
|
|
6
|
+
// embeds (test/welcome-words.test.mjs). Nothing here may say a reward was claimed, saved or confirmed (hard rule 8): "Đã lưu
|
|
7
|
+
// project" is about projects.
|
|
8
|
+
//
|
|
9
|
+
// The page shows some of these in capitals, as the English was: the rail, the + THÊM PROJECT button (typed so by
|
|
10
|
+
// onboarding-window.mjs), and the section titles and the hint (by its stylesheet).
|
|
11
|
+
|
|
12
|
+
export const WELCOME_WORDS = {
|
|
13
|
+
// The page's own name, which the OS lists the window by (X8a), and the native window's, before the page loads (X8b).
|
|
14
|
+
title: 'Thiết lập KitCode',
|
|
15
|
+
windowTitle: 'Chào mừng bạn đến với KitCode',
|
|
16
|
+
|
|
17
|
+
// The title bar (src/onboarding-platform.mjs): its tooltip (X7e), and the close dot's tooltip and name (X7g, X7f).
|
|
18
|
+
chrome: {
|
|
19
|
+
drag: 'Kéo để di chuyển cửa sổ',
|
|
20
|
+
close: 'Đóng',
|
|
21
|
+
closeName: 'Đóng cửa sổ thiết lập',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// The rail's steps 01 and 02 (rows 54, 55).
|
|
25
|
+
rail: {
|
|
26
|
+
tracking: 'Thiết lập',
|
|
27
|
+
projects: 'Chọn project',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Step 01: the question (row 56), its radio group's name (X7c, row 56 again), and the answers (rows 57, 58), between "[ ]".
|
|
31
|
+
tracking: {
|
|
32
|
+
question: 'Bắt đầu tracking sau khi thiết lập Kitcode',
|
|
33
|
+
groupName: 'Bắt đầu tracking sau khi thiết lập Kitcode',
|
|
34
|
+
yes: 'CÓ',
|
|
35
|
+
no: 'KHÔNG',
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Step 02: the heading (row 59), the list's name (X7d), the button after "+ " (row 60), the hint beside it (row 61), the
|
|
39
|
+
// empty list (X4), and each project's remove button, its tooltip (X7a) and its name (X7b).
|
|
40
|
+
projects: {
|
|
41
|
+
heading: 'Chọn một hoặc nhiều folder local',
|
|
42
|
+
listName: 'Danh sách project của KitCode',
|
|
43
|
+
add: 'Thêm project',
|
|
44
|
+
hint: 'Các tệp hiện có sẽ được lấy làm mốc để theo dõi thay đổi.\nMã nguồn và mọi thay đổi đều chỉ được lưu trên máy bạn.',
|
|
45
|
+
empty: 'Chưa thêm project nào',
|
|
46
|
+
removeTitle: 'Gỡ project',
|
|
47
|
+
removeName: 'Gỡ {name}',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// The main button: between "+ " and " ->" (row 62); while saving (N18); saved (row 72); after a failed save (X5a).
|
|
51
|
+
buttons: {
|
|
52
|
+
save: 'ĐÃ XONG, MỞ KITCODE ĐỂ TÍNH CÔNG',
|
|
53
|
+
saving: 'Đang lưu…',
|
|
54
|
+
saved: 'Thiết lập hoàn tất.',
|
|
55
|
+
retry: 'Thử lưu lại',
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// The message line, after "/* ".
|
|
59
|
+
messages: {
|
|
60
|
+
ready: 'Chọn một hay nhiều project để tiếp tục', // row 77
|
|
61
|
+
suggested: 'Đã thêm {N} folder project. Kiểm tra và lưu để tiếp tục.', // row 64
|
|
62
|
+
loaded: 'Đã chọn {N} project', // row 70
|
|
63
|
+
stateFailed: 'Không thể tải trạng thái thiết lập trên máy. Hãy khởi động lại quá trình thiết lập và thử lại nhé!', // row 76
|
|
64
|
+
noBridge: 'Không thể kết nối với KitCode setup. Vui lòng đóng cửa sổ này và thử lại.', // row 73
|
|
65
|
+
picking: 'Đang mở trình chọn folder…', // N14
|
|
66
|
+
pickCanceled: 'Đã hủy chọn project', // row 65
|
|
67
|
+
readyToAdd: '{N} project đã sẵn sàng', // row 69
|
|
68
|
+
alreadyAdded: 'Project đã được thêm', // row 63
|
|
69
|
+
pickerFailed: 'Không thể mở trình chọn folder.\nHãy khởi động lại phần thiết lập và thử lại nhé!', // row 66
|
|
70
|
+
removed: 'Đã gỡ project', // row 67
|
|
71
|
+
removedFromSetup: 'Đã gỡ project khỏi phần thiết lập này.', // N15
|
|
72
|
+
removeFailed: 'Không thể gỡ project.', // N16a
|
|
73
|
+
removeFailedRetry: 'Không thể gỡ project. Hãy thử lại nhé!', // N16b
|
|
74
|
+
noProjects: 'Hãy chọn ít nhất một project', // row 68
|
|
75
|
+
saving: 'Đang lưu project và tùy chỉnh…', // row 75
|
|
76
|
+
saveFailed: 'Không thể lưu thiết lập.', // N17
|
|
77
|
+
requestFailed: 'Không thể gửi yêu cầu thiết lập. Yên tâm, lựa chọn của bạn vẫn còn đây.', // X5b
|
|
78
|
+
saved: 'Vậy là xong. KitCode sắp lên sóng!', // row 71
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// A tester build's alone (src/diagnostics.mjs): the rail's step 03 (N19), the step's heading (row 78), what the build sends
|
|
83
|
+
// and never holds (row 79, two paragraphs), and the invited email's label (row 80). Any other build's page holds none of it.
|
|
84
|
+
export const WELCOME_TESTER_WORDS = {
|
|
85
|
+
rail: 'Tìm sự cố',
|
|
86
|
+
heading: 'TÌM SỰ CỐ ĐỂ KHÔNG SAI SỐ',
|
|
87
|
+
text: 'KitCode gửi diagnostic log đến máy chủ, ghi lại các hoạt động và lỗi của KitCode (như lỗi khởi động tracker hoặc lỗi hook). Nhờ vậy, đội ngũ phát triển có thể truy vết sự cố mà không cần hỏi thêm bạn.\n\nDiagnostic log không chứa mã nguồn, tệp, prompt hay tên project của bạn.',
|
|
88
|
+
emailLabel: 'Email nhận lời mời (không bắt buộc): Giúp đội ngũ hỗ trợ tìm đúng diagnostic log của bạn nhanh hơn.',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// What the main process answers (src/onboarding-electron.mjs), which the page shows on its message line as it is.
|
|
92
|
+
export const WELCOME_ERRORS = {
|
|
93
|
+
invalidRequest: 'Yêu cầu thiết lập không hợp lệ.', // X6a, answered only to a sender other than Welcome's page
|
|
94
|
+
noProjects: 'Hãy chọn ít nhất một project', // X6c, row 68's
|
|
95
|
+
registerFailed: 'KitCode không thể thêm một trong các folder bạn vừa chọn.', // X6d
|
|
96
|
+
trackerFailed: 'Đã lưu project, nhưng tracker không khởi động được', // row 74
|
|
97
|
+
projectNotFound: 'Không tìm thấy project.', // X6e
|
|
98
|
+
removeFailed: 'KitCode không thể gỡ project này.', // X6f
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// A tester build's alone: an invited email that is not an address (X6b).
|
|
102
|
+
export const WELCOME_TESTER_ERRORS = {
|
|
103
|
+
invalidEmail: 'Email này có vẻ chưa đúng. Kiểm tra lại hoặc để trống nhé.',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// What the deck keeps English ("ko cần dịch", rows 81-83), and what is never shown (N13): marked lang="en" in the page.
|
|
107
|
+
// The status bar's codes (setup:ready, projects:removed…) are codes, and stay where the script sets them (K7).
|
|
108
|
+
export const WELCOME_ENGLISH = {
|
|
109
|
+
topTag: 'local-first setup', // row 81
|
|
110
|
+
mode: 'NORMAL', // row 82
|
|
111
|
+
shortcuts: '↑↓ move # space select # enter continue', // row 82
|
|
112
|
+
pending: 'PENDING', // row 83
|
|
113
|
+
tracked: 'TRACKED', // row 83
|
|
114
|
+
added: '{N} ADDED', // N13, display: none
|
|
115
|
+
};
|
package/src/window-page.mjs
CHANGED
|
@@ -7,12 +7,26 @@ import fs from 'node:fs';
|
|
|
7
7
|
// 5.3.0, under the SIL Open Font License 1.1 (fonts/<family>/OFL.txt, from github.com/google/fonts).
|
|
8
8
|
// Embedded as data: URLs, so a page asks no server for them, whether it is a file, a data: URL or
|
|
9
9
|
// served by the tracker. A character outside the subset falls back to the next font in the stack.
|
|
10
|
+
// Since 2026-10-22 also JetBrains Mono, Welcome's alone, which speaks Vietnamese: its latin and vietnamese subsets, from
|
|
11
|
+
// @fontsource/jetbrains-mono 5.3.0, each with the unicode-range Google Fonts gives it, so that the two read as one font.
|
|
12
|
+
// Fira Code has no Vietnamese subset (docs/plans/2026-10-22-vietnamese-setup-and-welcome.md, "Fonts"); the Mini keeps it.
|
|
13
|
+
const JETBRAINS_MONO_RANGES = {
|
|
14
|
+
latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
|
|
15
|
+
vietnamese: 'U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB',
|
|
16
|
+
};
|
|
17
|
+
|
|
10
18
|
const FONTS = {
|
|
11
19
|
'vt323': {family: 'VT323', faces: [{weight: 400, file: 'vt323/vt323-latin-400-normal.woff2'}]},
|
|
12
20
|
'fira-code': {
|
|
13
21
|
family: 'Fira Code',
|
|
14
22
|
faces: [400, 500, 700].map((weight) => ({weight, file: `fira-code/fira-code-latin-${weight}-normal.woff2`})),
|
|
15
23
|
},
|
|
24
|
+
'jetbrains-mono': {
|
|
25
|
+
family: 'JetBrains Mono',
|
|
26
|
+
faces: [400, 500, 700].flatMap((weight) => ['vietnamese', 'latin'].map((subset) => (
|
|
27
|
+
{weight, file: `jetbrains-mono/jetbrains-mono-${subset}-${weight}-normal.woff2`, range: JETBRAINS_MONO_RANGES[subset]}
|
|
28
|
+
))),
|
|
29
|
+
},
|
|
16
30
|
};
|
|
17
31
|
|
|
18
32
|
const fontFaceCache = new Map();
|
|
@@ -21,9 +35,9 @@ export function fontFaces(names) {
|
|
|
21
35
|
const key = names.join(',');
|
|
22
36
|
|
|
23
37
|
if (!fontFaceCache.has(key)) {
|
|
24
|
-
fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file}) => {
|
|
38
|
+
fontFaceCache.set(key, names.flatMap((name) => FONTS[name].faces.map(({weight, file, range}) => {
|
|
25
39
|
const data = fs.readFileSync(new URL(`./fonts/${file}`, import.meta.url)).toString('base64');
|
|
26
|
-
return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')}`;
|
|
40
|
+
return `@font-face{font-family:'${FONTS[name].family}';font-style:normal;font-weight:${weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format('woff2')${range ? `;unicode-range:${range}` : ''}}`;
|
|
27
41
|
})).join('\n'));
|
|
28
42
|
}
|
|
29
43
|
|