termi-kids 0.1.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.
- package/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* My Page: a personal homepage scaffold.
|
|
3
|
+
* SAFETY DESIGN: the kid fills name, about, and favorites with editable
|
|
4
|
+
* fields IN THE PAGE ITSELF (contenteditable saved to localStorage),
|
|
5
|
+
* never through the chat. So personal details never reach the model.
|
|
6
|
+
* The page shows a friendly tip: use a nickname, keep real details secret.
|
|
7
|
+
* Photos are an emoji avatar picker. No uploads, no network.
|
|
8
|
+
*/
|
|
9
|
+
function jsData(value) {
|
|
10
|
+
return JSON.stringify(value, null, 2);
|
|
11
|
+
}
|
|
12
|
+
function escapeHtml(text) {
|
|
13
|
+
return text
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"');
|
|
18
|
+
}
|
|
19
|
+
const aboutMeTheme = {
|
|
20
|
+
id: 'about-me',
|
|
21
|
+
label: 'About Me',
|
|
22
|
+
emoji: '\u{1F31E}',
|
|
23
|
+
palette: { bg: '#fff8ef', fg: '#2b2540', accent: '#ff8a5c' },
|
|
24
|
+
glyphs: { shield: '\u{1F6E1}\u{FE0F}', spark: '\u{2728}', pin: '\u{1F4CC}' },
|
|
25
|
+
strings: {
|
|
26
|
+
heroPlaceholder: 'Your nickname here',
|
|
27
|
+
taglinePlaceholder: 'Add a fun motto here.',
|
|
28
|
+
aboutHeading: 'About me',
|
|
29
|
+
aboutPlaceholder: 'Write three fun facts about you.',
|
|
30
|
+
favoritesHeading: 'My favorites',
|
|
31
|
+
avatarHeading: 'Pick your avatar',
|
|
32
|
+
freshLabel: 'Start fresh',
|
|
33
|
+
freshConfirm: 'Click again to erase everything',
|
|
34
|
+
},
|
|
35
|
+
narrativeIntro: 'Your own corner of the screen. Fill it with the stuff you love.',
|
|
36
|
+
nonViolent: true,
|
|
37
|
+
nonCompetitive: true,
|
|
38
|
+
};
|
|
39
|
+
const myTeamTheme = {
|
|
40
|
+
id: 'my-team',
|
|
41
|
+
label: 'My Team',
|
|
42
|
+
emoji: '\u{1F3C5}',
|
|
43
|
+
palette: { bg: '#0f2557', fg: '#f4f8ff', accent: '#ffd23f' },
|
|
44
|
+
glyphs: { shield: '\u{1F6E1}\u{FE0F}', spark: '\u{26A1}', pin: '\u{1F4CC}' },
|
|
45
|
+
strings: {
|
|
46
|
+
heroPlaceholder: 'Your team name here',
|
|
47
|
+
taglinePlaceholder: 'Add your team motto here.',
|
|
48
|
+
aboutHeading: 'About our team',
|
|
49
|
+
aboutPlaceholder: 'What makes your team great? Write it here.',
|
|
50
|
+
favoritesHeading: 'Team favorites',
|
|
51
|
+
avatarHeading: 'Pick your mascot',
|
|
52
|
+
freshLabel: 'Start fresh',
|
|
53
|
+
freshConfirm: 'Click again to erase everything',
|
|
54
|
+
},
|
|
55
|
+
narrativeIntro: 'Every great team needs a home page. This one is yours.',
|
|
56
|
+
nonViolent: true,
|
|
57
|
+
nonCompetitive: true,
|
|
58
|
+
};
|
|
59
|
+
const aboutMeData = {
|
|
60
|
+
avatars: [
|
|
61
|
+
'\u{1F98A}',
|
|
62
|
+
'\u{1F43C}',
|
|
63
|
+
'\u{1F438}',
|
|
64
|
+
'\u{1F984}',
|
|
65
|
+
'\u{1F419}',
|
|
66
|
+
'\u{1F996}',
|
|
67
|
+
'\u{1F431}',
|
|
68
|
+
'\u{1F436}',
|
|
69
|
+
'\u{1F989}',
|
|
70
|
+
'\u{1F422}',
|
|
71
|
+
'\u{1F427}',
|
|
72
|
+
'\u{1F41D}',
|
|
73
|
+
],
|
|
74
|
+
favorites: [
|
|
75
|
+
'Favorite game',
|
|
76
|
+
'Favorite food',
|
|
77
|
+
'Favorite animal',
|
|
78
|
+
'Favorite color',
|
|
79
|
+
'Favorite song',
|
|
80
|
+
'Favorite place',
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const myTeamData = {
|
|
84
|
+
avatars: [
|
|
85
|
+
'\u{1F985}',
|
|
86
|
+
'\u{1F43A}',
|
|
87
|
+
'\u{1F981}',
|
|
88
|
+
'\u{1F42F}',
|
|
89
|
+
'\u{1F988}',
|
|
90
|
+
'\u{1F409}',
|
|
91
|
+
'\u{1F43B}',
|
|
92
|
+
'\u{1F40E}',
|
|
93
|
+
'\u{1F994}',
|
|
94
|
+
'\u{1F40D}',
|
|
95
|
+
'\u{1F99C}',
|
|
96
|
+
'\u{1F42C}',
|
|
97
|
+
],
|
|
98
|
+
favorites: [
|
|
99
|
+
'Our sport or game',
|
|
100
|
+
'Team colors',
|
|
101
|
+
'Best win ever',
|
|
102
|
+
'Team snack',
|
|
103
|
+
'Home field',
|
|
104
|
+
'Our team cheer',
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
const SAFETY_TIP = 'Use your nickname. Keep your real name, school, and address secret.';
|
|
108
|
+
function buildHtml(theme, prettyName) {
|
|
109
|
+
const s = theme.strings;
|
|
110
|
+
return `<!DOCTYPE html>
|
|
111
|
+
<html lang="en">
|
|
112
|
+
<head>
|
|
113
|
+
<meta charset="UTF-8">
|
|
114
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
115
|
+
<title>${escapeHtml(prettyName)}</title>
|
|
116
|
+
<link rel="icon" href="data:,">
|
|
117
|
+
<link rel="stylesheet" href="style.css">
|
|
118
|
+
<script src="game.js" defer></script>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<main class="frame">
|
|
122
|
+
<p class="safety-tip">${theme.glyphs['shield'] ?? ''} Tip: ${SAFETY_TIP}</p>
|
|
123
|
+
<header class="card hero">
|
|
124
|
+
<div id="avatar">${theme.emoji}</div>
|
|
125
|
+
<h1 id="nickname" contenteditable="true" data-save="nickname" data-placeholder="${s['heroPlaceholder'] ?? ''}"></h1>
|
|
126
|
+
<p id="motto" contenteditable="true" data-save="motto" data-placeholder="${s['taglinePlaceholder'] ?? ''}"></p>
|
|
127
|
+
</header>
|
|
128
|
+
<section class="card">
|
|
129
|
+
<h2>${s['avatarHeading'] ?? 'Pick your avatar'}</h2>
|
|
130
|
+
<div id="avatar-picker"></div>
|
|
131
|
+
</section>
|
|
132
|
+
<section class="card">
|
|
133
|
+
<h2>${s['aboutHeading'] ?? 'About me'}</h2>
|
|
134
|
+
<div id="about" class="about" contenteditable="true" data-save="about" data-placeholder="${s['aboutPlaceholder'] ?? ''}"></div>
|
|
135
|
+
</section>
|
|
136
|
+
<section class="card">
|
|
137
|
+
<h2>${s['favoritesHeading'] ?? 'My favorites'}</h2>
|
|
138
|
+
<div id="favorites"></div>
|
|
139
|
+
</section>
|
|
140
|
+
<footer>
|
|
141
|
+
<span id="save-note"></span>
|
|
142
|
+
<button id="fresh" class="ghost" type="button">${s['freshLabel'] ?? 'Start fresh'}</button>
|
|
143
|
+
</footer>
|
|
144
|
+
<p class="tip">Everything you type saves on this computer only. Nothing goes online.</p>
|
|
145
|
+
</main>
|
|
146
|
+
</body>
|
|
147
|
+
</html>
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
function buildCss(theme) {
|
|
151
|
+
const p = theme.palette;
|
|
152
|
+
return `/* My Page styles. Colors come from your THEME in game.js. */
|
|
153
|
+
* { box-sizing: border-box; }
|
|
154
|
+
:root {
|
|
155
|
+
--bg: ${p.bg};
|
|
156
|
+
--fg: ${p.fg};
|
|
157
|
+
--accent: ${p.accent};
|
|
158
|
+
}
|
|
159
|
+
body {
|
|
160
|
+
margin: 0;
|
|
161
|
+
min-height: 100vh;
|
|
162
|
+
background: var(--bg);
|
|
163
|
+
background-image: radial-gradient(circle at 50% 0%, color-mix(in srgb, var(--accent) 16%, var(--bg)), var(--bg) 65%);
|
|
164
|
+
color: var(--fg);
|
|
165
|
+
font-family: system-ui, "Segoe UI", Roboto, Arial, sans-serif;
|
|
166
|
+
display: flex;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
padding: 28px 16px;
|
|
169
|
+
}
|
|
170
|
+
.frame { width: min(680px, 100%); }
|
|
171
|
+
.safety-tip {
|
|
172
|
+
background: color-mix(in srgb, var(--accent) 22%, var(--bg));
|
|
173
|
+
border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--bg));
|
|
174
|
+
border-radius: 12px;
|
|
175
|
+
padding: 10px 14px;
|
|
176
|
+
font-size: 0.92rem;
|
|
177
|
+
font-weight: 600;
|
|
178
|
+
margin: 0 0 16px;
|
|
179
|
+
}
|
|
180
|
+
.card {
|
|
181
|
+
background: color-mix(in srgb, var(--fg) 6%, var(--bg));
|
|
182
|
+
border: 1px solid color-mix(in srgb, var(--fg) 14%, var(--bg));
|
|
183
|
+
border-radius: 16px;
|
|
184
|
+
padding: 20px;
|
|
185
|
+
margin-bottom: 16px;
|
|
186
|
+
}
|
|
187
|
+
.hero { text-align: center; }
|
|
188
|
+
#avatar { font-size: 4.5rem; line-height: 1.1; }
|
|
189
|
+
#nickname { font-size: 2rem; margin: 8px 0 4px; min-height: 1.2em; }
|
|
190
|
+
#motto { margin: 0; opacity: 0.85; min-height: 1.2em; }
|
|
191
|
+
h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
|
192
|
+
[contenteditable] {
|
|
193
|
+
border-radius: 8px;
|
|
194
|
+
padding: 4px 8px;
|
|
195
|
+
white-space: pre-wrap;
|
|
196
|
+
outline: 2px dashed transparent;
|
|
197
|
+
transition: outline-color 0.15s ease;
|
|
198
|
+
}
|
|
199
|
+
[contenteditable]:hover { outline-color: color-mix(in srgb, var(--accent) 50%, var(--bg)); }
|
|
200
|
+
[contenteditable]:focus { outline-color: var(--accent); }
|
|
201
|
+
[contenteditable]:empty::before {
|
|
202
|
+
content: attr(data-placeholder);
|
|
203
|
+
opacity: 0.45;
|
|
204
|
+
}
|
|
205
|
+
.about { min-height: 4em; line-height: 1.55; }
|
|
206
|
+
#avatar-picker {
|
|
207
|
+
display: grid;
|
|
208
|
+
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
|
|
209
|
+
gap: 8px;
|
|
210
|
+
}
|
|
211
|
+
.avatar-btn {
|
|
212
|
+
font-size: 1.7rem;
|
|
213
|
+
background: color-mix(in srgb, var(--fg) 8%, var(--bg));
|
|
214
|
+
border: 2px solid transparent;
|
|
215
|
+
border-radius: 12px;
|
|
216
|
+
padding: 6px 0;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
transition: transform 0.12s ease;
|
|
219
|
+
}
|
|
220
|
+
.avatar-btn:hover { transform: translateY(-2px); }
|
|
221
|
+
.avatar-btn.selected { border-color: var(--accent); }
|
|
222
|
+
.avatar-btn:focus-visible { outline: 3px solid var(--accent); }
|
|
223
|
+
#favorites {
|
|
224
|
+
display: grid;
|
|
225
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
226
|
+
gap: 12px;
|
|
227
|
+
}
|
|
228
|
+
.fav-tile {
|
|
229
|
+
background: color-mix(in srgb, var(--fg) 8%, var(--bg));
|
|
230
|
+
border-radius: 12px;
|
|
231
|
+
padding: 12px;
|
|
232
|
+
}
|
|
233
|
+
.fav-label {
|
|
234
|
+
font-size: 0.78rem;
|
|
235
|
+
font-weight: 700;
|
|
236
|
+
text-transform: uppercase;
|
|
237
|
+
letter-spacing: 0.04em;
|
|
238
|
+
opacity: 0.7;
|
|
239
|
+
margin: 0 0 6px;
|
|
240
|
+
}
|
|
241
|
+
.fav-value { min-height: 1.4em; font-size: 1.02rem; }
|
|
242
|
+
footer {
|
|
243
|
+
display: flex;
|
|
244
|
+
justify-content: space-between;
|
|
245
|
+
align-items: center;
|
|
246
|
+
}
|
|
247
|
+
#save-note { color: var(--accent); font-weight: 700; font-size: 0.9rem; }
|
|
248
|
+
.ghost {
|
|
249
|
+
background: transparent;
|
|
250
|
+
color: var(--fg);
|
|
251
|
+
border: 1px solid color-mix(in srgb, var(--fg) 35%, var(--bg));
|
|
252
|
+
border-radius: 10px;
|
|
253
|
+
padding: 8px 14px;
|
|
254
|
+
cursor: pointer;
|
|
255
|
+
}
|
|
256
|
+
.ghost:hover { border-color: var(--accent); color: var(--accent); }
|
|
257
|
+
.tip { opacity: 0.65; font-size: 0.85rem; margin-top: 14px; }
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
function buildGameJs(theme, data) {
|
|
261
|
+
return `// My Page: ${theme.label}. Made with Termi.
|
|
262
|
+
//
|
|
263
|
+
// HOW YOUR PAGE WORKS:
|
|
264
|
+
// 1. Click any dotted box on the page and type. It saves on this computer.
|
|
265
|
+
// 2. AVATARS below holds your avatar choices. Add more emoji!
|
|
266
|
+
// 3. FAVORITES holds the labels for your favorites grid. Rename them!
|
|
267
|
+
// Remember the tip on the page: nickname only, real details stay secret.
|
|
268
|
+
|
|
269
|
+
const THEME = ${jsData({
|
|
270
|
+
id: theme.id,
|
|
271
|
+
label: theme.label,
|
|
272
|
+
emoji: theme.emoji,
|
|
273
|
+
palette: theme.palette,
|
|
274
|
+
glyphs: theme.glyphs,
|
|
275
|
+
strings: theme.strings,
|
|
276
|
+
})};
|
|
277
|
+
|
|
278
|
+
// === YOUR PAGE SETUP (edit this part!) ===
|
|
279
|
+
const AVATARS = ${jsData(data.avatars)};
|
|
280
|
+
const FAVORITES = ${jsData(data.favorites)};
|
|
281
|
+
// === END OF YOUR PAGE SETUP ===
|
|
282
|
+
|
|
283
|
+
// ----- The page engine starts here. Curious? Read on! -----
|
|
284
|
+
const avatarEl = document.getElementById("avatar");
|
|
285
|
+
const pickerEl = document.getElementById("avatar-picker");
|
|
286
|
+
const favoritesEl = document.getElementById("favorites");
|
|
287
|
+
const saveNoteEl = document.getElementById("save-note");
|
|
288
|
+
const freshBtn = document.getElementById("fresh");
|
|
289
|
+
|
|
290
|
+
const PREFIX = "termi-mypage-" + THEME.id + ":";
|
|
291
|
+
let saveNoteTimer = null;
|
|
292
|
+
let freshArmed = false;
|
|
293
|
+
let freshTimer = null;
|
|
294
|
+
|
|
295
|
+
function load(field) {
|
|
296
|
+
try {
|
|
297
|
+
return localStorage.getItem(PREFIX + field) || "";
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function save(field, value) {
|
|
304
|
+
try {
|
|
305
|
+
localStorage.setItem(PREFIX + field, value);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
flashSaved();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function flashSaved() {
|
|
313
|
+
saveNoteEl.textContent = "Saved " + THEME.glyphs.spark;
|
|
314
|
+
clearTimeout(saveNoteTimer);
|
|
315
|
+
saveNoteTimer = setTimeout(function () {
|
|
316
|
+
saveNoteEl.textContent = "";
|
|
317
|
+
}, 1400);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function wireEditable(el) {
|
|
321
|
+
const field = el.getAttribute("data-save");
|
|
322
|
+
const saved = load(field);
|
|
323
|
+
if (saved) {
|
|
324
|
+
el.textContent = saved;
|
|
325
|
+
}
|
|
326
|
+
el.addEventListener("input", function () {
|
|
327
|
+
if (el.textContent.trim() === "") {
|
|
328
|
+
el.textContent = "";
|
|
329
|
+
}
|
|
330
|
+
save(field, el.innerText);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildFavorites() {
|
|
335
|
+
FAVORITES.forEach(function (label, i) {
|
|
336
|
+
const tile = document.createElement("div");
|
|
337
|
+
tile.className = "fav-tile";
|
|
338
|
+
const labelEl = document.createElement("p");
|
|
339
|
+
labelEl.className = "fav-label";
|
|
340
|
+
labelEl.textContent = THEME.glyphs.pin + " " + label;
|
|
341
|
+
const valueEl = document.createElement("div");
|
|
342
|
+
valueEl.className = "fav-value";
|
|
343
|
+
valueEl.contentEditable = "true";
|
|
344
|
+
valueEl.setAttribute("data-save", "fav-" + i);
|
|
345
|
+
valueEl.setAttribute("data-placeholder", "Type it here");
|
|
346
|
+
tile.appendChild(labelEl);
|
|
347
|
+
tile.appendChild(valueEl);
|
|
348
|
+
favoritesEl.appendChild(tile);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function markSelected(emoji) {
|
|
353
|
+
const buttons = pickerEl.querySelectorAll("button");
|
|
354
|
+
buttons.forEach(function (b) {
|
|
355
|
+
b.classList.toggle("selected", b.textContent === emoji);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function buildPicker() {
|
|
360
|
+
AVATARS.forEach(function (emoji) {
|
|
361
|
+
const btn = document.createElement("button");
|
|
362
|
+
btn.className = "avatar-btn";
|
|
363
|
+
btn.type = "button";
|
|
364
|
+
btn.textContent = emoji;
|
|
365
|
+
btn.addEventListener("click", function () {
|
|
366
|
+
avatarEl.textContent = emoji;
|
|
367
|
+
save("avatar", emoji);
|
|
368
|
+
markSelected(emoji);
|
|
369
|
+
});
|
|
370
|
+
pickerEl.appendChild(btn);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function initAvatar() {
|
|
375
|
+
const saved = load("avatar");
|
|
376
|
+
if (saved) {
|
|
377
|
+
avatarEl.textContent = saved;
|
|
378
|
+
markSelected(saved);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function startFresh() {
|
|
383
|
+
if (!freshArmed) {
|
|
384
|
+
freshArmed = true;
|
|
385
|
+
freshBtn.textContent = THEME.strings.freshConfirm;
|
|
386
|
+
clearTimeout(freshTimer);
|
|
387
|
+
freshTimer = setTimeout(function () {
|
|
388
|
+
freshArmed = false;
|
|
389
|
+
freshBtn.textContent = THEME.strings.freshLabel;
|
|
390
|
+
}, 3000);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const doomed = [];
|
|
395
|
+
for (let i = 0; i < localStorage.length; i += 1) {
|
|
396
|
+
const key = localStorage.key(i);
|
|
397
|
+
if (key && key.indexOf(PREFIX) === 0) {
|
|
398
|
+
doomed.push(key);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
doomed.forEach(function (key) {
|
|
402
|
+
localStorage.removeItem(key);
|
|
403
|
+
});
|
|
404
|
+
} catch (err) {
|
|
405
|
+
// Storage is off. Nothing to erase.
|
|
406
|
+
}
|
|
407
|
+
window.location.reload();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
buildFavorites();
|
|
411
|
+
buildPicker();
|
|
412
|
+
document.querySelectorAll("[data-save]").forEach(wireEditable);
|
|
413
|
+
initAvatar();
|
|
414
|
+
freshBtn.addEventListener("click", startFresh);
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
function buildTermiMd(theme, prettyName, data) {
|
|
418
|
+
return [
|
|
419
|
+
`# ${prettyName}`,
|
|
420
|
+
'',
|
|
421
|
+
'## What this is',
|
|
422
|
+
`A personal ${theme.label} homepage. The kid types right on the page, and it saves on this computer only.`,
|
|
423
|
+
'',
|
|
424
|
+
'## Files',
|
|
425
|
+
'- index.html: the page and its editable boxes.',
|
|
426
|
+
'- style.css: the colors and look.',
|
|
427
|
+
'- game.js: the avatar and favorites lists, then the page engine.',
|
|
428
|
+
'',
|
|
429
|
+
'## Built so far',
|
|
430
|
+
`- Starter ${theme.label} page with ${data.favorites.length} favorites tiles and ${data.avatars.length} avatars.`,
|
|
431
|
+
'',
|
|
432
|
+
'## Recap line',
|
|
433
|
+
`We built a ${theme.label} page you can fill in right in the browser.`,
|
|
434
|
+
'',
|
|
435
|
+
].join('\n');
|
|
436
|
+
}
|
|
437
|
+
function dataFor(theme) {
|
|
438
|
+
return theme.id === 'my-team' ? myTeamData : aboutMeData;
|
|
439
|
+
}
|
|
440
|
+
export const websitesScaffold = {
|
|
441
|
+
id: 'websites',
|
|
442
|
+
label: 'My Page',
|
|
443
|
+
emoji: '\u{1F310}',
|
|
444
|
+
ageNote: 'Great for ages 9 and up. A page about you, saved only on this computer.',
|
|
445
|
+
themes: [aboutMeTheme, myTeamTheme],
|
|
446
|
+
files(theme, prettyName) {
|
|
447
|
+
const data = dataFor(theme);
|
|
448
|
+
return {
|
|
449
|
+
'index.html': buildHtml(theme, prettyName),
|
|
450
|
+
'style.css': buildCss(theme),
|
|
451
|
+
'game.js': buildGameJs(theme, data),
|
|
452
|
+
'TERMI.md': buildTermiMd(theme, prettyName, data),
|
|
453
|
+
};
|
|
454
|
+
},
|
|
455
|
+
starterPrompts(theme) {
|
|
456
|
+
if (theme.id === 'my-team') {
|
|
457
|
+
return [
|
|
458
|
+
'Add a player list with five spots I can fill in',
|
|
459
|
+
'Add a win counter that goes up when I click it',
|
|
460
|
+
'Make the team name bounce when the page loads',
|
|
461
|
+
'Add a section for our game schedule',
|
|
462
|
+
'Let me pick from more mascot emojis',
|
|
463
|
+
];
|
|
464
|
+
}
|
|
465
|
+
return [
|
|
466
|
+
'Add a section for my pets',
|
|
467
|
+
'Make the colors change when I click the title',
|
|
468
|
+
'Add more emoji choices for my avatar',
|
|
469
|
+
'Give the favorites cards a rainbow border',
|
|
470
|
+
'Add a jokes corner with spots for three jokes',
|
|
471
|
+
];
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
export default websitesScaffold;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed snapshots for undo and redo.
|
|
3
|
+
*
|
|
4
|
+
* Layout under snapshotsDir()/<slug>/:
|
|
5
|
+
* - blobs/<sha256> file contents, one blob per unique content
|
|
6
|
+
* - manifests/<n>.json { n, ts, files: { relPath: sha } } per saved state
|
|
7
|
+
* - state.json { pointer } = the manifest that matches disk now
|
|
8
|
+
*
|
|
9
|
+
* Only kid files are snapshotted. TERMI.md, .termi.json, dotfiles, and
|
|
10
|
+
* vendored engine files are never captured, restored, or deleted.
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { atomicWriteFileSync, snapshotsDir } from '../config/paths.js';
|
|
16
|
+
/** How many manifests survive a prune. */
|
|
17
|
+
export const keepManifestCount = 50;
|
|
18
|
+
function sha256(buf) {
|
|
19
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function sameFiles(a, b) {
|
|
22
|
+
const aKeys = Object.keys(a);
|
|
23
|
+
const bKeys = Object.keys(b);
|
|
24
|
+
if (aKeys.length !== bKeys.length)
|
|
25
|
+
return false;
|
|
26
|
+
return aKeys.every((k) => a[k] === b[k]);
|
|
27
|
+
}
|
|
28
|
+
export function createSnapshotStore(project) {
|
|
29
|
+
const root = path.join(snapshotsDir(), project.meta.slug);
|
|
30
|
+
const blobsDir = path.join(root, 'blobs');
|
|
31
|
+
const manifestsDir = path.join(root, 'manifests');
|
|
32
|
+
const statePath = path.join(root, 'state.json');
|
|
33
|
+
const manifestPath = (n) => path.join(manifestsDir, `${n}.json`);
|
|
34
|
+
const readPointer = () => {
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
37
|
+
if (typeof data.pointer === 'number' && Number.isInteger(data.pointer) && data.pointer >= 0) {
|
|
38
|
+
return data.pointer;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// No state yet: pointer starts at zero.
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
};
|
|
46
|
+
const writePointer = (n) => {
|
|
47
|
+
atomicWriteFileSync(statePath, JSON.stringify({ pointer: n }) + '\n');
|
|
48
|
+
};
|
|
49
|
+
const listManifestNumbers = () => {
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = fs.readdirSync(manifestsDir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const nums = [];
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const match = /^(\d+)\.json$/.exec(name);
|
|
60
|
+
if (match && match[1] !== undefined)
|
|
61
|
+
nums.push(Number(match[1]));
|
|
62
|
+
}
|
|
63
|
+
nums.sort((a, b) => a - b);
|
|
64
|
+
return nums;
|
|
65
|
+
};
|
|
66
|
+
const readManifest = (n) => {
|
|
67
|
+
if (n < 1)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const data = JSON.parse(fs.readFileSync(manifestPath(n), 'utf8'));
|
|
71
|
+
if (typeof data.n !== 'number' || typeof data.files !== 'object' || data.files === null) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const projectFilePath = (relPath) => path.join(project.dir, ...relPath.split('/'));
|
|
81
|
+
/** Hashes the kid files on disk right now. */
|
|
82
|
+
const captureCurrent = () => {
|
|
83
|
+
const files = {};
|
|
84
|
+
const bySha = new Map();
|
|
85
|
+
for (const f of project.listKidFiles()) {
|
|
86
|
+
let buf;
|
|
87
|
+
try {
|
|
88
|
+
buf = fs.readFileSync(projectFilePath(f.relPath));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const sha = sha256(buf);
|
|
94
|
+
files[f.relPath] = sha;
|
|
95
|
+
bySha.set(sha, buf);
|
|
96
|
+
}
|
|
97
|
+
return { files, bySha };
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Saves the current state as manifest pointer+1.
|
|
101
|
+
* Any redo manifests above the pointer are dropped first.
|
|
102
|
+
*/
|
|
103
|
+
const persistCurrent = (current, pointer) => {
|
|
104
|
+
for (const n of listManifestNumbers()) {
|
|
105
|
+
if (n > pointer)
|
|
106
|
+
fs.rmSync(manifestPath(n), { force: true });
|
|
107
|
+
}
|
|
108
|
+
fs.mkdirSync(blobsDir, { recursive: true });
|
|
109
|
+
fs.mkdirSync(manifestsDir, { recursive: true });
|
|
110
|
+
for (const [sha, buf] of current.bySha) {
|
|
111
|
+
const blobPath = path.join(blobsDir, sha);
|
|
112
|
+
if (!fs.existsSync(blobPath))
|
|
113
|
+
fs.writeFileSync(blobPath, buf);
|
|
114
|
+
}
|
|
115
|
+
const next = pointer + 1;
|
|
116
|
+
const manifest = { n: next, ts: new Date().toISOString(), files: current.files };
|
|
117
|
+
atomicWriteFileSync(manifestPath(next), JSON.stringify(manifest) + '\n');
|
|
118
|
+
writePointer(next);
|
|
119
|
+
return next;
|
|
120
|
+
};
|
|
121
|
+
/** Makes sure the disk state is captured. Returns the pointer after. */
|
|
122
|
+
const ensureSnapshotted = () => {
|
|
123
|
+
const pointer = readPointer();
|
|
124
|
+
const current = captureCurrent();
|
|
125
|
+
const last = readManifest(pointer);
|
|
126
|
+
if (last !== null && sameFiles(last.files, current.files))
|
|
127
|
+
return pointer;
|
|
128
|
+
return persistCurrent(current, pointer);
|
|
129
|
+
};
|
|
130
|
+
/** Writes a manifest's files back to disk and removes kid files not in it. */
|
|
131
|
+
const restore = (manifest) => {
|
|
132
|
+
for (const sha of Object.values(manifest.files)) {
|
|
133
|
+
if (!fs.existsSync(path.join(blobsDir, sha)))
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
for (const f of project.listKidFiles()) {
|
|
137
|
+
if (!(f.relPath in manifest.files)) {
|
|
138
|
+
fs.rmSync(projectFilePath(f.relPath), { force: true });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const [relPath, sha] of Object.entries(manifest.files)) {
|
|
142
|
+
const blob = fs.readFileSync(path.join(blobsDir, sha));
|
|
143
|
+
const dest = projectFilePath(relPath);
|
|
144
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
145
|
+
fs.writeFileSync(dest, blob);
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
};
|
|
149
|
+
/** Keeps the newest manifests and drops blobs nothing points at. */
|
|
150
|
+
const prune = () => {
|
|
151
|
+
const nums = listManifestNumbers();
|
|
152
|
+
if (nums.length > keepManifestCount) {
|
|
153
|
+
for (const n of nums.slice(0, nums.length - keepManifestCount)) {
|
|
154
|
+
fs.rmSync(manifestPath(n), { force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const referenced = new Set();
|
|
158
|
+
for (const n of listManifestNumbers()) {
|
|
159
|
+
const manifest = readManifest(n);
|
|
160
|
+
if (manifest === null)
|
|
161
|
+
continue;
|
|
162
|
+
for (const sha of Object.values(manifest.files))
|
|
163
|
+
referenced.add(sha);
|
|
164
|
+
}
|
|
165
|
+
let blobNames;
|
|
166
|
+
try {
|
|
167
|
+
blobNames = fs.readdirSync(blobsDir);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const name of blobNames) {
|
|
173
|
+
if (!referenced.has(name))
|
|
174
|
+
fs.rmSync(path.join(blobsDir, name), { force: true });
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
beginTurn() {
|
|
179
|
+
ensureSnapshotted();
|
|
180
|
+
prune();
|
|
181
|
+
},
|
|
182
|
+
undo() {
|
|
183
|
+
const pointer = ensureSnapshotted();
|
|
184
|
+
const target = readManifest(pointer - 1);
|
|
185
|
+
if (target === null)
|
|
186
|
+
return false;
|
|
187
|
+
if (!restore(target))
|
|
188
|
+
return false;
|
|
189
|
+
writePointer(pointer - 1);
|
|
190
|
+
return true;
|
|
191
|
+
},
|
|
192
|
+
redo() {
|
|
193
|
+
const pointer = readPointer();
|
|
194
|
+
const target = readManifest(pointer + 1);
|
|
195
|
+
if (target === null)
|
|
196
|
+
return false;
|
|
197
|
+
if (!restore(target))
|
|
198
|
+
return false;
|
|
199
|
+
writePointer(pointer + 1);
|
|
200
|
+
return true;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|