humanjs-core 1.0.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +655 -0
- package/examples/api-example/app.js +365 -0
- package/examples/counter/app.js +141 -0
- package/examples/human-counter/app.human +27 -0
- package/examples/human-counter/app.js +77 -0
- package/examples/routing/app.js +129 -0
- package/examples/simple-js/app.js +30 -0
- package/examples/todo-app/app.js +378 -0
- package/examples/user-dashboard/app.js +0 -0
- package/package.json +78 -0
- package/scripts/human-compile.js +43 -0
- package/scripts/humanjs.js +700 -0
- package/src/compiler/human.js +194 -0
- package/src/core/component.js +381 -0
- package/src/core/events.js +130 -0
- package/src/core/render.js +173 -0
- package/src/core/router.js +274 -0
- package/src/core/state.js +114 -0
- package/src/index.js +61 -0
- package/src/plugins/http.js +167 -0
- package/src/plugins/storage.js +181 -0
- package/src/plugins/validator.js +193 -0
- package/src/utils/dom.js +0 -0
- package/src/utils/helpers.js +209 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
|
|
5
|
+
import { compileHuman } from '../src/compiler/human.js';
|
|
6
|
+
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`
|
|
9
|
+
humanjs
|
|
10
|
+
|
|
11
|
+
create <name> scaffold a new app
|
|
12
|
+
compile <in> <out> compile .human → .js
|
|
13
|
+
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [, , command, arg1, arg2] = process.argv;
|
|
18
|
+
|
|
19
|
+
if (!command || command === '--help' || command === '-h') {
|
|
20
|
+
printHelp();
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (command === 'compile') { await compileCommand(arg1, arg2); process.exit(0); }
|
|
25
|
+
if (command === 'create') { await createCommand(arg1); process.exit(0); }
|
|
26
|
+
|
|
27
|
+
console.error(` unknown command: ${command}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
|
|
30
|
+
async function compileCommand(inputPath, outputPath) {
|
|
31
|
+
if (!inputPath || !outputPath) throw new Error('missing paths');
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const absIn = path.resolve(cwd, inputPath);
|
|
34
|
+
const absOut = path.resolve(cwd, outputPath);
|
|
35
|
+
const source = await readFile(absIn, 'utf8');
|
|
36
|
+
const appImportPath = await resolveAppImportPath(cwd, absOut);
|
|
37
|
+
const compiled = compileHuman(source, { appImportPath });
|
|
38
|
+
await writeFile(absOut, compiled, 'utf8');
|
|
39
|
+
console.log(` ${path.relative(cwd, absIn)} → ${path.relative(cwd, absOut)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createCommand(appName) {
|
|
43
|
+
if (!appName) throw new Error('missing name');
|
|
44
|
+
const targetDir = path.resolve(process.cwd(), appName);
|
|
45
|
+
await ensureMissing(targetDir);
|
|
46
|
+
|
|
47
|
+
const folders = ['src', 'src/api', 'src/components', 'src/layouts', 'src/routes', 'src/styles'];
|
|
48
|
+
await Promise.all(folders.map(f => mkdir(path.join(targetDir, f), { recursive: true })));
|
|
49
|
+
|
|
50
|
+
const files = {
|
|
51
|
+
'package.json': createPackageJson(appName),
|
|
52
|
+
'index.html': createIndexHtml(appName),
|
|
53
|
+
'README.md': createReadme(appName),
|
|
54
|
+
'src/main.js': createMainJs(),
|
|
55
|
+
'src/app.js': createAppJs(),
|
|
56
|
+
'src/api/client.js': createApiClientJs(),
|
|
57
|
+
'src/components/BrandMark.js': createBrandMarkJs(),
|
|
58
|
+
'src/components/NavLink.js': createNavLinkJs(),
|
|
59
|
+
'src/components/Badge.js': createBadgeJs(),
|
|
60
|
+
'src/layouts/AppLayout.js': createAppLayoutJs(),
|
|
61
|
+
'src/routes/home.js': createHomeRouteJs(),
|
|
62
|
+
'src/routes/about.js': createAboutRouteJs(),
|
|
63
|
+
'src/routes/user.js': createUserRouteJs(),
|
|
64
|
+
'src/routes/not-found.js': createNotFoundRouteJs(),
|
|
65
|
+
'src/styles/theme.css': createThemeCss(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await Promise.all(
|
|
69
|
+
Object.entries(files).map(([rel, content]) =>
|
|
70
|
+
writeFile(path.join(targetDir, rel), content, 'utf8')
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
console.log(`\n created: ${appName}\n`);
|
|
75
|
+
console.log(` cd ${appName}`);
|
|
76
|
+
console.log(` npm install`);
|
|
77
|
+
console.log(` npm start\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function ensureMissing(targetDir) {
|
|
81
|
+
try {
|
|
82
|
+
await access(targetDir);
|
|
83
|
+
throw new Error(`already exists: ${path.basename(targetDir)}`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code !== 'ENOENT') throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveAppImportPath(cwd, absoluteOutput) {
|
|
90
|
+
try {
|
|
91
|
+
const pkg = JSON.parse(await readFile(path.resolve(cwd, 'package.json'), 'utf8'));
|
|
92
|
+
const localEntry = path.resolve(cwd, 'src/index.js');
|
|
93
|
+
if (pkg.name === 'human-js' || pkg.name === '@kaiserofthenight/human-js' || pkg.name === '@kaiserofthenight/human-js-test') {
|
|
94
|
+
const rel = path.relative(path.dirname(absoluteOutput), localEntry).split(path.sep).join('/');
|
|
95
|
+
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
return 'humanjs-core';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Scaffold files ───────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function createPackageJson(appName) {
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
name: appName,
|
|
106
|
+
private: true,
|
|
107
|
+
type: 'module',
|
|
108
|
+
scripts: {
|
|
109
|
+
start: 'python -m http.server 3000 || python3 -m http.server 3000 || npx serve . --listen 3000',
|
|
110
|
+
dev: 'python -m http.server 3000 || python3 -m http.server 3000 || npx serve . --listen 3000',
|
|
111
|
+
},
|
|
112
|
+
dependencies: {
|
|
113
|
+
'humanjs-core': '^1.0.0',
|
|
114
|
+
},
|
|
115
|
+
}, null, 2) + '\n';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createIndexHtml(appName) {
|
|
119
|
+
return `<!doctype html>
|
|
120
|
+
<html lang="en">
|
|
121
|
+
<head>
|
|
122
|
+
<meta charset="UTF-8" />
|
|
123
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
124
|
+
<title>${appName}</title>
|
|
125
|
+
<link rel="icon" href="https://human-js.vercel.app/logo.png" />
|
|
126
|
+
<script type="importmap">
|
|
127
|
+
{
|
|
128
|
+
"imports": {
|
|
129
|
+
"humanjs-core": "./node_modules/humanjs-core/src/index.js",
|
|
130
|
+
"@/": "./src/"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
134
|
+
<link rel="stylesheet" href="./src/styles/theme.css" />
|
|
135
|
+
</head>
|
|
136
|
+
<body>
|
|
137
|
+
<div id="app"></div>
|
|
138
|
+
<script type="module" src="./src/main.js"></script>
|
|
139
|
+
</body>
|
|
140
|
+
</html>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createReadme(appName) {
|
|
145
|
+
return `# ${appName}
|
|
146
|
+
|
|
147
|
+
\`\`\`bash
|
|
148
|
+
npm install && npm start
|
|
149
|
+
\`\`\`
|
|
150
|
+
|
|
151
|
+
Open [localhost:3000](http://localhost:3000)
|
|
152
|
+
|
|
153
|
+
## Structure
|
|
154
|
+
|
|
155
|
+
\`\`\`
|
|
156
|
+
src/
|
|
157
|
+
api/ data fetching & clients
|
|
158
|
+
components/ reusable UI pieces
|
|
159
|
+
layouts/ page shells (header, footer)
|
|
160
|
+
routes/ one file per page
|
|
161
|
+
styles/ theme.css — tokens + base styles
|
|
162
|
+
app.js router setup
|
|
163
|
+
main.js entry point
|
|
164
|
+
\`\`\`
|
|
165
|
+
|
|
166
|
+
## Imports
|
|
167
|
+
|
|
168
|
+
Use the \`@/\` alias for local imports:
|
|
169
|
+
|
|
170
|
+
\`\`\`js
|
|
171
|
+
import { homeRoute } from '@/routes/home.js';
|
|
172
|
+
import { AppLayout } from '@/layouts/AppLayout.js';
|
|
173
|
+
\`\`\`
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createMainJs() {
|
|
178
|
+
return `import '@/app.js';\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createAppJs() {
|
|
182
|
+
return `import { createRouter } from 'humanjs-core';
|
|
183
|
+
import { AppLayout } from '@/layouts/AppLayout.js';
|
|
184
|
+
import { homeRoute } from '@/routes/home.js';
|
|
185
|
+
import { aboutRoute } from '@/routes/about.js';
|
|
186
|
+
import { userRoute } from '@/routes/user.js';
|
|
187
|
+
import { notFoundRoute } from '@/routes/not-found.js';
|
|
188
|
+
|
|
189
|
+
createRouter(
|
|
190
|
+
{
|
|
191
|
+
'/': {
|
|
192
|
+
layout: ({ children }) => AppLayout({ title: 'Home', children }),
|
|
193
|
+
...homeRoute,
|
|
194
|
+
},
|
|
195
|
+
'/about': {
|
|
196
|
+
layout: ({ children }) => AppLayout({ title: 'About', children }),
|
|
197
|
+
render: aboutRoute,
|
|
198
|
+
},
|
|
199
|
+
'/user/:id': {
|
|
200
|
+
layout: ({ children, params }) => AppLayout({ title: \`User \${params.id}\`, children }),
|
|
201
|
+
render: userRoute,
|
|
202
|
+
},
|
|
203
|
+
'*': {
|
|
204
|
+
layout: ({ children }) => AppLayout({ title: 'Not found', children }),
|
|
205
|
+
render: notFoundRoute,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{ root: document.getElementById('app') }
|
|
209
|
+
);
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function createApiClientJs() {
|
|
214
|
+
return `// Replace with your real API base URL
|
|
215
|
+
const BASE = 'https://jsonplaceholder.typicode.com';
|
|
216
|
+
|
|
217
|
+
export async function getUser(id) {
|
|
218
|
+
const res = await fetch(\`\${BASE}/users/\${id}\`);
|
|
219
|
+
if (!res.ok) throw new Error(\`HTTP \${res.status}\`);
|
|
220
|
+
return res.json();
|
|
221
|
+
}
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function createBrandMarkJs() {
|
|
226
|
+
return `import { component, html } from 'humanjs-core';
|
|
227
|
+
|
|
228
|
+
export const BrandMark = component(({ compact = false }) => html\`
|
|
229
|
+
<a class="brand\${compact ? ' brand--sm' : ''}" href="#/">
|
|
230
|
+
<img class="brand__logo" src="https://human-js.vercel.app/logo.png" alt="" />
|
|
231
|
+
<span class="brand__name">Human<em>JS</em></span>
|
|
232
|
+
</a>
|
|
233
|
+
\`);
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createNavLinkJs() {
|
|
238
|
+
return `import { component, html } from 'humanjs-core';
|
|
239
|
+
|
|
240
|
+
export const NavLink = component(({ href, label }) => html\`
|
|
241
|
+
<a class="nav-link" href="#\${href}">\${label}</a>
|
|
242
|
+
\`);
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function createBadgeJs() {
|
|
247
|
+
return `import { component, html } from 'humanjs-core';
|
|
248
|
+
|
|
249
|
+
// tone: 'purple' | 'cyan' | 'pink' | 'green' | 'yellow' | 'default'
|
|
250
|
+
export const Badge = component(({ label, tone = 'default' }) => html\`
|
|
251
|
+
<span class="badge badge--\${tone}">\${label}</span>
|
|
252
|
+
\`);
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function createAppLayoutJs() {
|
|
257
|
+
return `import { layout, html } from 'humanjs-core';
|
|
258
|
+
import { BrandMark } from '@/components/BrandMark.js';
|
|
259
|
+
import { NavLink } from '@/components/NavLink.js';
|
|
260
|
+
|
|
261
|
+
export const AppLayout = layout(({ title = 'HumanJS', children }) => html\`
|
|
262
|
+
<div class="app">
|
|
263
|
+
|
|
264
|
+
<header class="header">
|
|
265
|
+
<div class="container header__inner">
|
|
266
|
+
\${BrandMark({})}
|
|
267
|
+
<nav class="nav">
|
|
268
|
+
\${NavLink({ href: '/', label: 'Home' })}
|
|
269
|
+
\${NavLink({ href: '/about', label: 'About' })}
|
|
270
|
+
\${NavLink({ href: '/user/1?tab=overview', label: 'User' })}
|
|
271
|
+
</nav>
|
|
272
|
+
</div>
|
|
273
|
+
</header>
|
|
274
|
+
|
|
275
|
+
<main class="main container">
|
|
276
|
+
<h1 class="page-title">\${title}</h1>
|
|
277
|
+
<div class="page-body">
|
|
278
|
+
\${children}
|
|
279
|
+
</div>
|
|
280
|
+
</main>
|
|
281
|
+
|
|
282
|
+
<footer class="footer">
|
|
283
|
+
<div class="container footer__inner">
|
|
284
|
+
\${BrandMark({ compact: true })}
|
|
285
|
+
<span class="footer__note">MIT license</span>
|
|
286
|
+
</div>
|
|
287
|
+
</footer>
|
|
288
|
+
|
|
289
|
+
</div>
|
|
290
|
+
\`);
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function createHomeRouteJs() {
|
|
295
|
+
return `import { html } from 'humanjs-core';
|
|
296
|
+
import { Badge } from '@/components/Badge.js';
|
|
297
|
+
|
|
298
|
+
export const homeRoute = {
|
|
299
|
+
state: { count: 0 },
|
|
300
|
+
actions: {
|
|
301
|
+
inc({ state }) { state.count += 1; },
|
|
302
|
+
dec({ state }) { state.count -= 1; },
|
|
303
|
+
reset({ state }) { state.count = 0; },
|
|
304
|
+
},
|
|
305
|
+
render: (_, { state }) => html\`
|
|
306
|
+
<div class="stack">
|
|
307
|
+
|
|
308
|
+
<section class="panel">
|
|
309
|
+
<p class="label">counter</p>
|
|
310
|
+
<h2 class="counter">\${state.count}</h2>
|
|
311
|
+
<div class="btn-row">
|
|
312
|
+
<button data-click="dec" class="btn btn--ghost">−</button>
|
|
313
|
+
<button data-click="reset" class="btn">reset</button>
|
|
314
|
+
<button data-click="inc" class="btn btn--primary">+</button>
|
|
315
|
+
</div>
|
|
316
|
+
</section>
|
|
317
|
+
|
|
318
|
+
<section class="panel">
|
|
319
|
+
<p class="label">stack</p>
|
|
320
|
+
<div class="tag-row">
|
|
321
|
+
\${Badge({ label: 'zero build', tone: 'purple' })}
|
|
322
|
+
\${Badge({ label: '~5kb', tone: 'cyan' })}
|
|
323
|
+
\${Badge({ label: 'no magic', tone: 'pink' })}
|
|
324
|
+
\${Badge({ label: 'plain JS', tone: 'green' })}
|
|
325
|
+
</div>
|
|
326
|
+
</section>
|
|
327
|
+
|
|
328
|
+
</div>
|
|
329
|
+
\`,
|
|
330
|
+
};
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function createAboutRouteJs() {
|
|
335
|
+
return `import { html } from 'humanjs-core';
|
|
336
|
+
|
|
337
|
+
export function aboutRoute() {
|
|
338
|
+
return html\`
|
|
339
|
+
<div class="stack">
|
|
340
|
+
<section class="panel">
|
|
341
|
+
<p class="label">structure</p>
|
|
342
|
+
<ul class="file-list">
|
|
343
|
+
<li><code>src/api/</code> — data & fetch helpers</li>
|
|
344
|
+
<li><code>src/components/</code>— reusable UI pieces</li>
|
|
345
|
+
<li><code>src/layouts/</code> — page shells</li>
|
|
346
|
+
<li><code>src/routes/</code> — one file per page</li>
|
|
347
|
+
<li><code>src/styles/</code> — tokens & base CSS</li>
|
|
348
|
+
</ul>
|
|
349
|
+
</section>
|
|
350
|
+
</div>
|
|
351
|
+
\`;
|
|
352
|
+
}
|
|
353
|
+
`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function createUserRouteJs() {
|
|
357
|
+
return `import { html, getParams } from 'humanjs-core';
|
|
358
|
+
import { getUser } from '@/api/client.js';
|
|
359
|
+
|
|
360
|
+
export async function userRoute(params) {
|
|
361
|
+
const query = getParams();
|
|
362
|
+
let user = null;
|
|
363
|
+
let error = null;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
user = await getUser(params.id);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
error = e.message;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (error) return html\`
|
|
372
|
+
<section class="panel">
|
|
373
|
+
<p class="label error">error</p>
|
|
374
|
+
<p>\${error}</p>
|
|
375
|
+
</section>
|
|
376
|
+
\`;
|
|
377
|
+
|
|
378
|
+
return html\`
|
|
379
|
+
<div class="stack">
|
|
380
|
+
<section class="panel">
|
|
381
|
+
<p class="label">user #\${params.id}</p>
|
|
382
|
+
<h2 class="user-name">\${user.name}</h2>
|
|
383
|
+
<p class="user-meta">\${user.email}</p>
|
|
384
|
+
<p class="user-meta">\${user.company?.name}</p>
|
|
385
|
+
</section>
|
|
386
|
+
<section class="panel">
|
|
387
|
+
<p class="label">active tab</p>
|
|
388
|
+
<p>\${query.tab || 'overview'}</p>
|
|
389
|
+
</section>
|
|
390
|
+
</div>
|
|
391
|
+
\`;
|
|
392
|
+
}
|
|
393
|
+
`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createNotFoundRouteJs() {
|
|
397
|
+
return `import { html } from 'humanjs-core';
|
|
398
|
+
|
|
399
|
+
export function notFoundRoute() {
|
|
400
|
+
return html\`
|
|
401
|
+
<section class="panel">
|
|
402
|
+
<p class="label error">404</p>
|
|
403
|
+
<p>Page not found. <a href="#/">Go home</a></p>
|
|
404
|
+
</section>
|
|
405
|
+
\`;
|
|
406
|
+
}
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function createThemeCss() {
|
|
411
|
+
return `@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,700;0,800;1,400&display=swap');
|
|
412
|
+
|
|
413
|
+
/* ── Reset ───────────────────────────────────── */
|
|
414
|
+
|
|
415
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
416
|
+
|
|
417
|
+
/* ── Tokens ──────────────────────────────────── */
|
|
418
|
+
|
|
419
|
+
:root {
|
|
420
|
+
/* Dracula / Catppuccin Mocha */
|
|
421
|
+
--bg: #1e1e2e;
|
|
422
|
+
--bg-alt: #181825;
|
|
423
|
+
--surface: #313244;
|
|
424
|
+
--overlay: #45475a;
|
|
425
|
+
--muted: #585b70;
|
|
426
|
+
--subtle: #6c7086;
|
|
427
|
+
--text: #cdd6f4;
|
|
428
|
+
--subtext: #a6adc8;
|
|
429
|
+
|
|
430
|
+
/* Accent palette */
|
|
431
|
+
--purple: #cba6f7;
|
|
432
|
+
--pink: #f38ba8;
|
|
433
|
+
--cyan: #89dceb;
|
|
434
|
+
--blue: #89b4fa;
|
|
435
|
+
--green: #a6e3a1;
|
|
436
|
+
--yellow: #f9e2af;
|
|
437
|
+
--peach: #fab387;
|
|
438
|
+
|
|
439
|
+
/* Semantic */
|
|
440
|
+
--border: var(--surface);
|
|
441
|
+
--link: var(--blue);
|
|
442
|
+
|
|
443
|
+
/* Scale */
|
|
444
|
+
--radius: 12px;
|
|
445
|
+
--radius-sm: 7px;
|
|
446
|
+
--font: 'JetBrains Mono', ui-monospace, monospace;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/* ── Base ────────────────────────────────────── */
|
|
450
|
+
|
|
451
|
+
html { color-scheme: dark; }
|
|
452
|
+
|
|
453
|
+
body {
|
|
454
|
+
font-family: var(--font);
|
|
455
|
+
background: var(--bg-alt);
|
|
456
|
+
color: var(--text);
|
|
457
|
+
font-size: 13px;
|
|
458
|
+
line-height: 1.65;
|
|
459
|
+
min-height: 100vh;
|
|
460
|
+
-webkit-font-smoothing: antialiased;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
a { color: var(--link); }
|
|
464
|
+
img { display: block; }
|
|
465
|
+
code { font-family: var(--font); font-size: .9em; color: var(--cyan); }
|
|
466
|
+
button { font: inherit; cursor: pointer; }
|
|
467
|
+
|
|
468
|
+
/* ── Layout ──────────────────────────────────── */
|
|
469
|
+
|
|
470
|
+
.app {
|
|
471
|
+
display: grid;
|
|
472
|
+
grid-template-rows: auto 1fr auto;
|
|
473
|
+
min-height: 100vh;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.container {
|
|
477
|
+
width: min(100% - 40px, 900px);
|
|
478
|
+
margin-inline: auto;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/* ── Header ──────────────────────────────────── */
|
|
482
|
+
|
|
483
|
+
.header {
|
|
484
|
+
background: var(--bg);
|
|
485
|
+
border-bottom: 1px solid var(--border);
|
|
486
|
+
position: sticky;
|
|
487
|
+
top: 0;
|
|
488
|
+
z-index: 10;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.header__inner {
|
|
492
|
+
display: flex;
|
|
493
|
+
align-items: center;
|
|
494
|
+
justify-content: space-between;
|
|
495
|
+
padding-block: 14px;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* ── Brand ───────────────────────────────────── */
|
|
499
|
+
|
|
500
|
+
.brand {
|
|
501
|
+
display: flex;
|
|
502
|
+
align-items: center;
|
|
503
|
+
gap: 9px;
|
|
504
|
+
text-decoration: none;
|
|
505
|
+
color: var(--text);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.brand__logo {
|
|
509
|
+
width: 28px;
|
|
510
|
+
height: 28px;
|
|
511
|
+
border-radius: var(--radius-sm);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.brand__name {
|
|
515
|
+
font-size: 14px;
|
|
516
|
+
font-weight: 800;
|
|
517
|
+
letter-spacing: .05em;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.brand__name em {
|
|
521
|
+
font-style: normal;
|
|
522
|
+
color: var(--purple);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
.brand--sm .brand__logo { width: 22px; height: 22px; }
|
|
526
|
+
.brand--sm .brand__name { font-size: 12px; }
|
|
527
|
+
|
|
528
|
+
/* ── Nav ─────────────────────────────────────── */
|
|
529
|
+
|
|
530
|
+
.nav { display: flex; gap: 2px; }
|
|
531
|
+
|
|
532
|
+
.nav-link {
|
|
533
|
+
display: inline-block;
|
|
534
|
+
padding: 6px 12px;
|
|
535
|
+
border-radius: var(--radius-sm);
|
|
536
|
+
font-size: 12px;
|
|
537
|
+
font-weight: 600;
|
|
538
|
+
letter-spacing: .04em;
|
|
539
|
+
color: var(--subtle);
|
|
540
|
+
text-decoration: none;
|
|
541
|
+
transition: color .15s, background .15s;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.nav-link:hover {
|
|
545
|
+
color: var(--cyan);
|
|
546
|
+
background: rgba(137, 220, 235, .08);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ── Main ────────────────────────────────────── */
|
|
550
|
+
|
|
551
|
+
.main {
|
|
552
|
+
padding-block: 44px 72px;
|
|
553
|
+
display: grid;
|
|
554
|
+
gap: 6px;
|
|
555
|
+
align-content: start;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.page-title {
|
|
559
|
+
font-size: clamp(28px, 5vw, 46px);
|
|
560
|
+
font-weight: 800;
|
|
561
|
+
letter-spacing: -.04em;
|
|
562
|
+
line-height: 1;
|
|
563
|
+
color: var(--text);
|
|
564
|
+
margin-bottom: 28px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.page-body { display: grid; gap: 16px; }
|
|
568
|
+
|
|
569
|
+
/* ── Stack (vertical spacing util) ──────────────*/
|
|
570
|
+
|
|
571
|
+
.stack { display: grid; gap: 14px; }
|
|
572
|
+
|
|
573
|
+
/* ── Panel ───────────────────────────────────── */
|
|
574
|
+
|
|
575
|
+
.panel {
|
|
576
|
+
background: var(--bg);
|
|
577
|
+
border: 1px solid var(--border);
|
|
578
|
+
border-radius: var(--radius);
|
|
579
|
+
padding: 24px;
|
|
580
|
+
display: grid;
|
|
581
|
+
gap: 14px;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.label {
|
|
585
|
+
font-size: 10px;
|
|
586
|
+
font-weight: 700;
|
|
587
|
+
letter-spacing: .15em;
|
|
588
|
+
text-transform: uppercase;
|
|
589
|
+
color: var(--subtle);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.label.error { color: var(--pink); }
|
|
593
|
+
|
|
594
|
+
/* ── Counter ─────────────────────────────────── */
|
|
595
|
+
|
|
596
|
+
.counter {
|
|
597
|
+
font-size: clamp(56px, 12vw, 96px);
|
|
598
|
+
font-weight: 800;
|
|
599
|
+
letter-spacing: -.05em;
|
|
600
|
+
line-height: 1;
|
|
601
|
+
color: var(--purple);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
605
|
+
|
|
606
|
+
/* ── Buttons ─────────────────────────────────── */
|
|
607
|
+
|
|
608
|
+
.btn {
|
|
609
|
+
padding: 9px 18px;
|
|
610
|
+
border-radius: var(--radius-sm);
|
|
611
|
+
font-size: 12px;
|
|
612
|
+
font-weight: 700;
|
|
613
|
+
letter-spacing: .04em;
|
|
614
|
+
background: var(--surface);
|
|
615
|
+
border: 1px solid var(--border);
|
|
616
|
+
color: var(--text);
|
|
617
|
+
transition: opacity .15s, transform .1s;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.btn:hover { opacity: .75; }
|
|
621
|
+
.btn:active { transform: scale(.97); }
|
|
622
|
+
|
|
623
|
+
.btn--primary {
|
|
624
|
+
background: var(--purple);
|
|
625
|
+
border-color: transparent;
|
|
626
|
+
color: var(--bg-alt);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.btn--ghost {
|
|
630
|
+
background: transparent;
|
|
631
|
+
color: var(--subtle);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* ── Badge ───────────────────────────────────── */
|
|
635
|
+
|
|
636
|
+
.tag-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
637
|
+
|
|
638
|
+
.badge {
|
|
639
|
+
display: inline-block;
|
|
640
|
+
padding: 4px 10px;
|
|
641
|
+
border-radius: 999px;
|
|
642
|
+
font-size: 11px;
|
|
643
|
+
font-weight: 700;
|
|
644
|
+
letter-spacing: .04em;
|
|
645
|
+
background: var(--surface);
|
|
646
|
+
color: var(--subtext);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.badge--purple { background: rgba(203,166,247,.15); color: var(--purple); }
|
|
650
|
+
.badge--cyan { background: rgba(137,220,235,.15); color: var(--cyan); }
|
|
651
|
+
.badge--pink { background: rgba(243,139,168,.15); color: var(--pink); }
|
|
652
|
+
.badge--green { background: rgba(166,227,161,.15); color: var(--green); }
|
|
653
|
+
.badge--yellow { background: rgba(249,226,175,.15); color: var(--yellow); }
|
|
654
|
+
.badge--blue { background: rgba(137,180,250,.15); color: var(--blue); }
|
|
655
|
+
|
|
656
|
+
/* ── File list ───────────────────────────────── */
|
|
657
|
+
|
|
658
|
+
.file-list {
|
|
659
|
+
list-style: none;
|
|
660
|
+
display: grid;
|
|
661
|
+
gap: 8px;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.file-list li { color: var(--subtext); font-size: 12px; }
|
|
665
|
+
.file-list li code { color: var(--cyan); }
|
|
666
|
+
|
|
667
|
+
/* ── User route ──────────────────────────────── */
|
|
668
|
+
|
|
669
|
+
.user-name { font-size: 22px; font-weight: 800; letter-spacing: -.02em; }
|
|
670
|
+
.user-meta { font-size: 12px; color: var(--subtext); }
|
|
671
|
+
|
|
672
|
+
/* ── Footer ──────────────────────────────────── */
|
|
673
|
+
|
|
674
|
+
.footer {
|
|
675
|
+
background: var(--bg);
|
|
676
|
+
border-top: 1px solid var(--border);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.footer__inner {
|
|
680
|
+
display: flex;
|
|
681
|
+
align-items: center;
|
|
682
|
+
justify-content: space-between;
|
|
683
|
+
padding-block: 14px;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.footer__note {
|
|
687
|
+
font-size: 11px;
|
|
688
|
+
color: var(--muted);
|
|
689
|
+
letter-spacing: .06em;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/* ── Mobile ──────────────────────────────────── */
|
|
693
|
+
|
|
694
|
+
@media (max-width: 580px) {
|
|
695
|
+
.header__inner { flex-wrap: wrap; gap: 10px; }
|
|
696
|
+
.page-title { margin-bottom: 20px; }
|
|
697
|
+
.panel { padding: 18px; }
|
|
698
|
+
}
|
|
699
|
+
`;
|
|
700
|
+
}
|