@bytesbrains/weblocks 0.5.0 → 0.6.1
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/AGENT.md +11 -1
- package/CATALOG.md +42 -3
- package/CHANGELOG.md +36 -0
- package/README.md +7 -4
- package/catalog.json +250 -2
- package/lib/blocks/experience.d.ts +2 -0
- package/lib/blocks/experience.js +78 -0
- package/lib/blocks/hero.js +37 -4
- package/lib/blocks/profileHeader.d.ts +2 -0
- package/lib/blocks/profileHeader.js +124 -0
- package/lib/blocks/skills.d.ts +2 -0
- package/lib/blocks/skills.js +91 -0
- package/lib/islands/resume.d.ts +1 -0
- package/lib/islands/resume.js +42 -0
- package/lib/registry.js +8 -0
- package/lib/render.js +22 -2
- package/lib/types.d.ts +5 -0
- package/package.json +3 -2
package/AGENT.md
CHANGED
|
@@ -46,10 +46,11 @@ its exact config schema:
|
|
|
46
46
|
- `catalogPrompt()` — a compact text menu for a system prompt.
|
|
47
47
|
- `CATALOG.md` — the same, human-readable.
|
|
48
48
|
|
|
49
|
-
The
|
|
49
|
+
The 45 block types, by group:
|
|
50
50
|
|
|
51
51
|
- **Chrome/app-shell:** `nav`, `app-shell`, `sidebar`, `announcement-bar`, `footer`
|
|
52
52
|
- **Heroes:** `hero`, `hero-app`
|
|
53
|
+
- **Résumé / profile:** `profile-header` (avatar, name, contacts, Download-PDF/Share buttons), `experience` (dated entries — reuse for Education/Certifications by changing its title), `skills` (tags or level bars)
|
|
53
54
|
- **Content:** `features`, `about`, `rich-text`, `split`, `steps`, `stats`,
|
|
54
55
|
`services-catalogue`, `pricing`, `logos`, `team`
|
|
55
56
|
- **Media:** `gallery`, `carousel`, `video`, `video-gallery`, `map`
|
|
@@ -166,6 +167,15 @@ capability it needs; the **host** wires the endpoint. If no runtime is wired, th
|
|
|
166
167
|
block renders inert-but-valid — that is expected, not an error. Never put secrets,
|
|
167
168
|
endpoints, captcha keys, or backend logic in the config.
|
|
168
169
|
|
|
170
|
+
## Favicon & hero banner
|
|
171
|
+
|
|
172
|
+
- **Favicon:** set `meta.favicon` to an icon **URL** or a single **emoji** (e.g.
|
|
173
|
+
`"🍞"`) — the engine emits the browser-tab `<link rel="icon">`.
|
|
174
|
+
- **Hero image banner:** give the `hero` block an `image` for a full-bleed
|
|
175
|
+
background banner; add `overlay` (`scrim`/`dark`/`light`/`none`) for text
|
|
176
|
+
legibility and `minHeight` (`sm`/`md`/`lg`/`full`). Text colour auto-flips over
|
|
177
|
+
dark overlays.
|
|
178
|
+
|
|
169
179
|
## PWA & SEO (optional, additive)
|
|
170
180
|
|
|
171
181
|
Add top-level fields to make the app installable / discoverable:
|
package/CATALOG.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# @bytesbrains/weblocks — Block Catalog (v0.
|
|
1
|
+
# @bytesbrains/weblocks — Block Catalog (v0.6.1)
|
|
2
2
|
|
|
3
3
|
The AI composes a `SiteManifest` (`{ meta, design, blocks[] }`) using **only** the block types below, then the engine validates + renders it to static HTML. This file is generated from the code (`npm run emit:catalog`) — do not edit by hand.
|
|
4
4
|
|
|
5
|
-
**Block types:** `app-shell` · `nav` · `announcement-bar` · `sidebar` · `hero` · `hero-app` · `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` · `gallery` · `carousel` · `video` · `video-gallery` · `map` · `timeline` · `tabs` · `accordion` · `testimonials` · `faq` · `blog-list` · `blog-post` · `feed` · `contact-form` · `newsletter` · `search` · `auth` · `cta` · `social-links` · `contact-details` · `directions` · `legal` · `divider` · `spacer` · `copyright` · `footer`
|
|
5
|
+
**Block types:** `app-shell` · `nav` · `announcement-bar` · `sidebar` · `hero` · `hero-app` · `profile-header` · `experience` · `skills` · `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` · `gallery` · `carousel` · `video` · `video-gallery` · `map` · `timeline` · `tabs` · `accordion` · `testimonials` · `faq` · `blog-list` · `blog-post` · `feed` · `contact-form` · `newsletter` · `search` · `auth` · `cta` · `social-links` · `contact-details` · `directions` · `legal` · `divider` · `spacer` · `copyright` · `footer`
|
|
6
6
|
|
|
7
7
|
## `app-shell`
|
|
8
8
|
|
|
@@ -47,7 +47,7 @@ A vertical section/drawer navigation for multi-view apps: an optional title over
|
|
|
47
47
|
|
|
48
48
|
## `hero`
|
|
49
49
|
|
|
50
|
-
Top-of-page
|
|
50
|
+
Top-of-page hero: a big headline with optional eyebrow, subheading, and CTA — optionally over a full-bleed background image banner with a legibility overlay.
|
|
51
51
|
|
|
52
52
|
| field | type | required | notes |
|
|
53
53
|
|---|---|---|---|
|
|
@@ -55,6 +55,9 @@ Top-of-page banner: a big headline with an optional eyebrow, subheading, and one
|
|
|
55
55
|
| `headline` | string | yes | |
|
|
56
56
|
| `subhead` | string | | |
|
|
57
57
|
| `align` | undefined (center\|left) | | |
|
|
58
|
+
| `image` | string | | |
|
|
59
|
+
| `overlay` | undefined (scrim\|dark\|light\|none) | | |
|
|
60
|
+
| `minHeight` | undefined (auto\|sm\|md\|lg\|full) | | |
|
|
58
61
|
| `cta` | object | | |
|
|
59
62
|
|
|
60
63
|
## `hero-app`
|
|
@@ -69,6 +72,42 @@ An app landing hero: a headline and subhead over a primary install button, with
|
|
|
69
72
|
| `installHref` | string | | |
|
|
70
73
|
| `screenshots` | array | | |
|
|
71
74
|
|
|
75
|
+
## `profile-header`
|
|
76
|
+
|
|
77
|
+
A résumé/CV header: avatar, name, role, location, a contact + social row, and optional Download-PDF and Share buttons.
|
|
78
|
+
|
|
79
|
+
| field | type | required | notes |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| `name` | string | yes | |
|
|
82
|
+
| `headline` | string | | |
|
|
83
|
+
| `location` | string | | |
|
|
84
|
+
| `avatar` | string | | |
|
|
85
|
+
| `summary` | string | | |
|
|
86
|
+
| `contacts` | array | | |
|
|
87
|
+
| `showDownload` | boolean | | |
|
|
88
|
+
| `downloadLabel` | string | | |
|
|
89
|
+
| `showShare` | boolean | | |
|
|
90
|
+
| `shareLabel` | string | | |
|
|
91
|
+
|
|
92
|
+
## `experience`
|
|
93
|
+
|
|
94
|
+
A résumé section of dated entries (role, org, period, location, bullets). Reuse for Experience, Education, Certifications by changing the title.
|
|
95
|
+
|
|
96
|
+
| field | type | required | notes |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| `title` | string | | |
|
|
99
|
+
| `items` | array | | |
|
|
100
|
+
|
|
101
|
+
## `skills`
|
|
102
|
+
|
|
103
|
+
Grouped skills shown as tags (with optional proficiency dots) or labelled level bars; also for languages and tools.
|
|
104
|
+
|
|
105
|
+
| field | type | required | notes |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `title` | string | | |
|
|
108
|
+
| `display` | undefined (tags\|bars) | | |
|
|
109
|
+
| `groups` | array | | |
|
|
110
|
+
|
|
72
111
|
## `features`
|
|
73
112
|
|
|
74
113
|
A grid of value propositions, each with an optional icon/emoji, a title, and a short line of text.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ follows [semantic versioning](https://semver.org): the **block catalog** and the
|
|
|
5
5
|
**`SiteManifest` shape** are the public contract — additive block/field changes
|
|
6
6
|
are minor, breaking changes to either are major.
|
|
7
7
|
|
|
8
|
+
## 0.6.1 — 2026-07-16
|
|
9
|
+
|
|
10
|
+
Docs only — no engine changes.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- A runnable **résumé example** (`npm run example:resume` → `resume-output.html`)
|
|
14
|
+
demonstrating the résumé pack + print-to-PDF end to end, and a README pointer
|
|
15
|
+
to it.
|
|
16
|
+
|
|
17
|
+
## 0.6.0 — 2026-07-16
|
|
18
|
+
|
|
19
|
+
A résumé/CV builder, hero image banners, and favicons. Additive and
|
|
20
|
+
**non-breaking** — every `0.5.0` manifest still validates and renders identically.
|
|
21
|
+
Catalog 42 → 45.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Résumé / CV pack** — turn the engine into a live-resume builder:
|
|
25
|
+
- **`profile-header`** — avatar (or initials), name, role, location, a contact +
|
|
26
|
+
social row (brand icons), and optional **Download-PDF** and **Share** buttons.
|
|
27
|
+
- **`experience`** — dated, structured entries (role, org, period, location,
|
|
28
|
+
summary, achievement bullets). Reuse for Experience / Education /
|
|
29
|
+
Certifications by changing its title.
|
|
30
|
+
- **`skills`** — grouped skills as tags (with proficiency dots) or level bars.
|
|
31
|
+
- **Print styles** — `renderSite` now emits an `@media print` stylesheet so any
|
|
32
|
+
page (especially a résumé) exports cleanly to PDF; the **`resume.js` island**
|
|
33
|
+
wires the header's Download (`window.print()`) and Share (Web Share API +
|
|
34
|
+
copy-link) buttons. `data-wl-noprint` elements are hidden from the printout.
|
|
35
|
+
- **`hero` image banner** — the `hero` block gains optional `image`, `overlay`
|
|
36
|
+
(scrim / dark / light / none), and `minHeight` (sm / md / lg / full). With an
|
|
37
|
+
image, the photo sits behind the content (an `<img>` + scrim) with a legibility
|
|
38
|
+
overlay and the text flips to a readable colour; with no image it renders
|
|
39
|
+
exactly as before.
|
|
40
|
+
- **`meta.favicon`** — a browser-tab favicon: a URL (svg/png/ico) **or a single
|
|
41
|
+
emoji** (rendered as an inline SVG data URI). `renderSite` emits a scheme-
|
|
42
|
+
sanitized `<link rel="icon">` in `<head>` when set.
|
|
43
|
+
|
|
8
44
|
## 0.5.0 — 2026-07-16
|
|
9
45
|
|
|
10
46
|
More media & footer blocks, brand icons, and shipped interactivity. Additive and
|
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ is never raw HTML:
|
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
103
|
type SiteManifest = {
|
|
104
|
-
meta: { title: string; description: string; lang: string };
|
|
104
|
+
meta: { title: string; description: string; lang: string; favicon?: string };
|
|
105
105
|
design: DesignTokens; // the shared baseplate (CSS custom properties)
|
|
106
106
|
blocks: Block[]; // ordered, typed page sections
|
|
107
107
|
version: number; // bumped per accepted edit → undo / history / diff
|
|
@@ -139,12 +139,13 @@ import catalogJson from '@bytesbrains/weblocks/catalog.json' with { type: 'json'
|
|
|
139
139
|
|
|
140
140
|
## Block catalog
|
|
141
141
|
|
|
142
|
-
**
|
|
142
|
+
**45 typed blocks.** Full field reference in [`CATALOG.md`](./CATALOG.md).
|
|
143
143
|
|
|
144
144
|
| Group | Blocks |
|
|
145
145
|
|---|---|
|
|
146
146
|
| Chrome / app-shell | `nav` · `app-shell` · `sidebar` · `announcement-bar` · `footer` |
|
|
147
147
|
| Heroes | `hero` · `hero-app` |
|
|
148
|
+
| Résumé / profile | `profile-header` · `experience` · `skills` (live CV — avatar, dated entries, skills, Download-PDF/Share) |
|
|
148
149
|
| Content | `features` · `about` · `rich-text` · `split` · `steps` · `stats` · `services-catalogue` · `pricing` · `logos` · `team` |
|
|
149
150
|
| Media | `gallery` · `carousel` · `video` · `video-gallery` · `map` |
|
|
150
151
|
| Location | `directions` (deep links to the visitor’s map app) |
|
|
@@ -246,6 +247,7 @@ island scripts** (zero-dependency, ~6 KB each) under a subpath:
|
|
|
246
247
|
| `gallery` (`lightbox: true`) | `lightbox.js` | click-to-zoom, prev/next, keyboard, swipe, Esc |
|
|
247
248
|
| `carousel` | `carousel.js` | arrows, dots, keyboard, optional autoplay |
|
|
248
249
|
| `video-gallery` | `video.js` | click-to-play cards (load the real player inline on press) |
|
|
250
|
+
| `profile-header` (Download/Share on) | `resume.js` | print-to-PDF (`window.print()`) + Web Share / copy-link |
|
|
249
251
|
|
|
250
252
|
Serve them at the island URL. Copy from the package's `./islands/*.js` export, e.g.:
|
|
251
253
|
|
|
@@ -296,8 +298,9 @@ typed schema (no raw-HTML field), consumes shared tokens, renders totally
|
|
|
296
298
|
npm install # dev deps only (typescript, @types/node)
|
|
297
299
|
npm run build # tsc → lib/
|
|
298
300
|
npm test # block definition-of-done + engine invariants
|
|
299
|
-
npm run example
|
|
300
|
-
npm run
|
|
301
|
+
npm run example # render a sample landing page → example-output.html
|
|
302
|
+
npm run example:resume # render a live résumé/CV → resume-output.html (try its Download-PDF)
|
|
303
|
+
npm run emit:catalog # regenerate catalog.json + CATALOG.md from code
|
|
301
304
|
```
|
|
302
305
|
|
|
303
306
|
Drive it end-to-end with a real model (dev harness — provider is env, not code):
|
package/catalog.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"package": "@bytesbrains/weblocks",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"description": "Block catalog for @bytesbrains/weblocks. Send this to the model as its API reference; it composes a SiteManifest ({ meta, design, blocks[] }) using ONLY these block types. The engine then validates and renders it.",
|
|
6
6
|
"blockTypes": [
|
|
7
7
|
"app-shell",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"sidebar",
|
|
11
11
|
"hero",
|
|
12
12
|
"hero-app",
|
|
13
|
+
"profile-header",
|
|
14
|
+
"experience",
|
|
15
|
+
"skills",
|
|
13
16
|
"features",
|
|
14
17
|
"about",
|
|
15
18
|
"rich-text",
|
|
@@ -237,7 +240,7 @@
|
|
|
237
240
|
},
|
|
238
241
|
{
|
|
239
242
|
"type": "hero",
|
|
240
|
-
"description": "Top-of-page
|
|
243
|
+
"description": "Top-of-page hero: a big headline with optional eyebrow, subheading, and CTA — optionally over a full-bleed background image banner with a legibility overlay.",
|
|
241
244
|
"schema": {
|
|
242
245
|
"type": "object",
|
|
243
246
|
"properties": {
|
|
@@ -263,6 +266,30 @@
|
|
|
263
266
|
],
|
|
264
267
|
"default": "center"
|
|
265
268
|
},
|
|
269
|
+
"image": {
|
|
270
|
+
"type": "string",
|
|
271
|
+
"maxLength": 500,
|
|
272
|
+
"default": ""
|
|
273
|
+
},
|
|
274
|
+
"overlay": {
|
|
275
|
+
"enum": [
|
|
276
|
+
"scrim",
|
|
277
|
+
"dark",
|
|
278
|
+
"light",
|
|
279
|
+
"none"
|
|
280
|
+
],
|
|
281
|
+
"default": "scrim"
|
|
282
|
+
},
|
|
283
|
+
"minHeight": {
|
|
284
|
+
"enum": [
|
|
285
|
+
"auto",
|
|
286
|
+
"sm",
|
|
287
|
+
"md",
|
|
288
|
+
"lg",
|
|
289
|
+
"full"
|
|
290
|
+
],
|
|
291
|
+
"default": "auto"
|
|
292
|
+
},
|
|
266
293
|
"cta": {
|
|
267
294
|
"type": "object",
|
|
268
295
|
"properties": {
|
|
@@ -342,6 +369,227 @@
|
|
|
342
369
|
"additionalProperties": false
|
|
343
370
|
}
|
|
344
371
|
},
|
|
372
|
+
{
|
|
373
|
+
"type": "profile-header",
|
|
374
|
+
"description": "A résumé/CV header: avatar, name, role, location, a contact + social row, and optional Download-PDF and Share buttons.",
|
|
375
|
+
"schema": {
|
|
376
|
+
"type": "object",
|
|
377
|
+
"properties": {
|
|
378
|
+
"name": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"maxLength": 120,
|
|
381
|
+
"default": ""
|
|
382
|
+
},
|
|
383
|
+
"headline": {
|
|
384
|
+
"type": "string",
|
|
385
|
+
"maxLength": 160,
|
|
386
|
+
"default": ""
|
|
387
|
+
},
|
|
388
|
+
"location": {
|
|
389
|
+
"type": "string",
|
|
390
|
+
"maxLength": 120,
|
|
391
|
+
"default": ""
|
|
392
|
+
},
|
|
393
|
+
"avatar": {
|
|
394
|
+
"type": "string",
|
|
395
|
+
"maxLength": 500,
|
|
396
|
+
"default": ""
|
|
397
|
+
},
|
|
398
|
+
"summary": {
|
|
399
|
+
"type": "string",
|
|
400
|
+
"maxLength": 400,
|
|
401
|
+
"default": ""
|
|
402
|
+
},
|
|
403
|
+
"contacts": {
|
|
404
|
+
"type": "array",
|
|
405
|
+
"items": {
|
|
406
|
+
"type": "object",
|
|
407
|
+
"properties": {
|
|
408
|
+
"type": {
|
|
409
|
+
"enum": [
|
|
410
|
+
"email",
|
|
411
|
+
"phone",
|
|
412
|
+
"website",
|
|
413
|
+
"location",
|
|
414
|
+
"linkedin",
|
|
415
|
+
"github",
|
|
416
|
+
"twitter",
|
|
417
|
+
"x",
|
|
418
|
+
"custom"
|
|
419
|
+
],
|
|
420
|
+
"default": "custom"
|
|
421
|
+
},
|
|
422
|
+
"value": {
|
|
423
|
+
"type": "string",
|
|
424
|
+
"maxLength": 300,
|
|
425
|
+
"default": ""
|
|
426
|
+
},
|
|
427
|
+
"label": {
|
|
428
|
+
"type": "string",
|
|
429
|
+
"maxLength": 80,
|
|
430
|
+
"default": ""
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
"required": [
|
|
434
|
+
"value"
|
|
435
|
+
],
|
|
436
|
+
"additionalProperties": false
|
|
437
|
+
},
|
|
438
|
+
"maxItems": 10
|
|
439
|
+
},
|
|
440
|
+
"showDownload": {
|
|
441
|
+
"type": "boolean",
|
|
442
|
+
"default": false
|
|
443
|
+
},
|
|
444
|
+
"downloadLabel": {
|
|
445
|
+
"type": "string",
|
|
446
|
+
"maxLength": 40,
|
|
447
|
+
"default": "Download PDF"
|
|
448
|
+
},
|
|
449
|
+
"showShare": {
|
|
450
|
+
"type": "boolean",
|
|
451
|
+
"default": false
|
|
452
|
+
},
|
|
453
|
+
"shareLabel": {
|
|
454
|
+
"type": "string",
|
|
455
|
+
"maxLength": 40,
|
|
456
|
+
"default": "Share"
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
"required": [
|
|
460
|
+
"name"
|
|
461
|
+
],
|
|
462
|
+
"additionalProperties": false
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
"type": "experience",
|
|
467
|
+
"description": "A résumé section of dated entries (role, org, period, location, bullets). Reuse for Experience, Education, Certifications by changing the title.",
|
|
468
|
+
"schema": {
|
|
469
|
+
"type": "object",
|
|
470
|
+
"properties": {
|
|
471
|
+
"title": {
|
|
472
|
+
"type": "string",
|
|
473
|
+
"maxLength": 120,
|
|
474
|
+
"default": "Experience"
|
|
475
|
+
},
|
|
476
|
+
"items": {
|
|
477
|
+
"type": "array",
|
|
478
|
+
"items": {
|
|
479
|
+
"type": "object",
|
|
480
|
+
"properties": {
|
|
481
|
+
"role": {
|
|
482
|
+
"type": "string",
|
|
483
|
+
"maxLength": 120,
|
|
484
|
+
"default": ""
|
|
485
|
+
},
|
|
486
|
+
"org": {
|
|
487
|
+
"type": "string",
|
|
488
|
+
"maxLength": 120,
|
|
489
|
+
"default": ""
|
|
490
|
+
},
|
|
491
|
+
"period": {
|
|
492
|
+
"type": "string",
|
|
493
|
+
"maxLength": 60,
|
|
494
|
+
"default": ""
|
|
495
|
+
},
|
|
496
|
+
"location": {
|
|
497
|
+
"type": "string",
|
|
498
|
+
"maxLength": 80,
|
|
499
|
+
"default": ""
|
|
500
|
+
},
|
|
501
|
+
"url": {
|
|
502
|
+
"type": "string",
|
|
503
|
+
"maxLength": 500,
|
|
504
|
+
"default": ""
|
|
505
|
+
},
|
|
506
|
+
"summary": {
|
|
507
|
+
"type": "string",
|
|
508
|
+
"maxLength": 300,
|
|
509
|
+
"default": ""
|
|
510
|
+
},
|
|
511
|
+
"bullets": {
|
|
512
|
+
"type": "array",
|
|
513
|
+
"items": {
|
|
514
|
+
"type": "string",
|
|
515
|
+
"maxLength": 300,
|
|
516
|
+
"default": ""
|
|
517
|
+
},
|
|
518
|
+
"maxItems": 12
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
"required": [
|
|
522
|
+
"role"
|
|
523
|
+
],
|
|
524
|
+
"additionalProperties": false
|
|
525
|
+
},
|
|
526
|
+
"maxItems": 24
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
"additionalProperties": false
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
"type": "skills",
|
|
534
|
+
"description": "Grouped skills shown as tags (with optional proficiency dots) or labelled level bars; also for languages and tools.",
|
|
535
|
+
"schema": {
|
|
536
|
+
"type": "object",
|
|
537
|
+
"properties": {
|
|
538
|
+
"title": {
|
|
539
|
+
"type": "string",
|
|
540
|
+
"maxLength": 120,
|
|
541
|
+
"default": "Skills"
|
|
542
|
+
},
|
|
543
|
+
"display": {
|
|
544
|
+
"enum": [
|
|
545
|
+
"tags",
|
|
546
|
+
"bars"
|
|
547
|
+
],
|
|
548
|
+
"default": "tags"
|
|
549
|
+
},
|
|
550
|
+
"groups": {
|
|
551
|
+
"type": "array",
|
|
552
|
+
"items": {
|
|
553
|
+
"type": "object",
|
|
554
|
+
"properties": {
|
|
555
|
+
"name": {
|
|
556
|
+
"type": "string",
|
|
557
|
+
"maxLength": 60,
|
|
558
|
+
"default": ""
|
|
559
|
+
},
|
|
560
|
+
"items": {
|
|
561
|
+
"type": "array",
|
|
562
|
+
"items": {
|
|
563
|
+
"type": "object",
|
|
564
|
+
"properties": {
|
|
565
|
+
"label": {
|
|
566
|
+
"type": "string",
|
|
567
|
+
"maxLength": 60,
|
|
568
|
+
"default": ""
|
|
569
|
+
},
|
|
570
|
+
"level": {
|
|
571
|
+
"type": "integer",
|
|
572
|
+
"minimum": 0,
|
|
573
|
+
"maximum": 5,
|
|
574
|
+
"default": 0
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
"required": [
|
|
578
|
+
"label"
|
|
579
|
+
],
|
|
580
|
+
"additionalProperties": false
|
|
581
|
+
},
|
|
582
|
+
"maxItems": 40
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
"additionalProperties": false
|
|
586
|
+
},
|
|
587
|
+
"maxItems": 10
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
"additionalProperties": false
|
|
591
|
+
}
|
|
592
|
+
},
|
|
345
593
|
{
|
|
346
594
|
"type": "features",
|
|
347
595
|
"description": "A grid of value propositions, each with an optional icon/emoji, a title, and a short line of text.",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `experience` — a résumé section of dated, structured entries: role, org,
|
|
3
|
+
* period, location, an optional summary, and achievement bullets. One block for
|
|
4
|
+
* the whole family: set its `title` to "Experience", "Education",
|
|
5
|
+
* "Certifications", "Volunteering", etc. Print-friendly (entries avoid page
|
|
6
|
+
* breaks). Static brick.
|
|
7
|
+
*/
|
|
8
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
9
|
+
const schema = {
|
|
10
|
+
title: { kind: 'string', default: 'Experience', max: 120 },
|
|
11
|
+
items: {
|
|
12
|
+
kind: 'array', max: 24,
|
|
13
|
+
of: {
|
|
14
|
+
kind: 'object',
|
|
15
|
+
fields: {
|
|
16
|
+
role: { kind: 'string', required: true, default: '', max: 120 }, // role / degree / title
|
|
17
|
+
org: { kind: 'string', default: '', max: 120 }, // company / school
|
|
18
|
+
period: { kind: 'string', default: '', max: 60 }, // e.g. "2021 – Present"
|
|
19
|
+
location: { kind: 'string', default: '', max: 80 },
|
|
20
|
+
url: { kind: 'string', default: '', max: 500 }, // org link
|
|
21
|
+
summary: { kind: 'string', default: '', max: 300 },
|
|
22
|
+
bullets: { kind: 'array', max: 12, of: { kind: 'string', default: '', max: 300 } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const css = `
|
|
28
|
+
.blk-experience{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
29
|
+
.blk-experience .wrap{max-width:820px;margin:0 auto}
|
|
30
|
+
.blk-experience h2{font-size:var(--fs-xl);margin:0 0 var(--space);font-weight:800}
|
|
31
|
+
.blk-experience .entry{padding:0 0 calc(var(--space)*1.3);margin-bottom:calc(var(--space)*1.3);border-bottom:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
|
|
32
|
+
.blk-experience .entry:last-child{border-bottom:0;margin-bottom:0}
|
|
33
|
+
.blk-experience .top{display:flex;justify-content:space-between;gap:1em;flex-wrap:wrap;align-items:baseline}
|
|
34
|
+
.blk-experience .role{font-weight:700;font-size:var(--fs-lg)}
|
|
35
|
+
.blk-experience .org{color:var(--primary);font-weight:600}
|
|
36
|
+
.blk-experience .org a{color:inherit;text-decoration:none}
|
|
37
|
+
.blk-experience .org a:hover{text-decoration:underline}
|
|
38
|
+
.blk-experience .period{color:var(--muted);font-size:var(--fs-base);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
39
|
+
.blk-experience .loc{color:var(--muted);font-size:var(--fs-base);margin:.1em 0 0}
|
|
40
|
+
.blk-experience .summary{margin:.5em 0 0;color:var(--muted)}
|
|
41
|
+
.blk-experience ul{margin:.5em 0 0;padding-left:1.3em}
|
|
42
|
+
.blk-experience li{margin:.25em 0;line-height:1.5}
|
|
43
|
+
`.trim();
|
|
44
|
+
function entry(it) {
|
|
45
|
+
if (!it.role)
|
|
46
|
+
return '';
|
|
47
|
+
const url = sanitizeUrl(it.url);
|
|
48
|
+
const org = it.org
|
|
49
|
+
? `<span class="org">${(url !== '#' && it.url) ? `<a href="${escapeAttr(url)}" rel="noopener">${escapeHtml(it.org)}</a>` : escapeHtml(it.org)}</span>`
|
|
50
|
+
: '';
|
|
51
|
+
const bullets = (it.bullets ?? []).filter(Boolean).map((b) => `<li>${escapeHtml(b)}</li>`).join('');
|
|
52
|
+
return `<div class="entry">
|
|
53
|
+
<div class="top">
|
|
54
|
+
<div><span class="role">${escapeHtml(it.role)}</span>${org ? ` · ${org}` : ''}</div>
|
|
55
|
+
${it.period ? `<span class="period">${escapeHtml(it.period)}</span>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
${it.location ? `<p class="loc">${escapeHtml(it.location)}</p>` : ''}
|
|
58
|
+
${it.summary ? `<p class="summary">${escapeHtml(it.summary)}</p>` : ''}
|
|
59
|
+
${bullets ? `<ul>${bullets}</ul>` : ''}
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
62
|
+
function render(config) {
|
|
63
|
+
const title = config.title;
|
|
64
|
+
const items = (config.items ?? []).map(entry).filter(Boolean);
|
|
65
|
+
return `<section class="blk-experience" aria-label="${escapeAttr(title || 'Experience')}">
|
|
66
|
+
<div class="wrap">
|
|
67
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
68
|
+
${items.join('\n ')}
|
|
69
|
+
</div>
|
|
70
|
+
</section>`;
|
|
71
|
+
}
|
|
72
|
+
export const experience = {
|
|
73
|
+
type: 'experience',
|
|
74
|
+
description: 'A résumé section of dated entries (role, org, period, location, bullets). Reuse for Experience, Education, Certifications by changing the title.',
|
|
75
|
+
schema,
|
|
76
|
+
css,
|
|
77
|
+
render,
|
|
78
|
+
};
|
package/lib/blocks/hero.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* `hero` — headline + optional eyebrow/subhead/CTA, optionally over a background
|
|
3
|
+
* **image banner**. When `image` is set the photo sits behind the content with a
|
|
4
|
+
* legibility overlay (scrim/dark/light) and the text flips to a readable colour;
|
|
5
|
+
* with no image it renders exactly as a plain text hero. Static brick.
|
|
6
|
+
*/
|
|
2
7
|
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
3
8
|
const schema = {
|
|
4
9
|
eyebrow: { kind: 'string', default: '', max: 80 },
|
|
5
10
|
headline: { kind: 'string', required: true, default: 'Your headline here', max: 160 },
|
|
6
11
|
subhead: { kind: 'string', default: '', max: 320 },
|
|
7
12
|
align: { kind: 'enum', values: ['center', 'left'], default: 'center' },
|
|
13
|
+
image: { kind: 'string', default: '', max: 500 }, // background banner image
|
|
14
|
+
overlay: { kind: 'enum', values: ['scrim', 'dark', 'light', 'none'], default: 'scrim' }, // legibility layer (with image)
|
|
15
|
+
minHeight: { kind: 'enum', values: ['auto', 'sm', 'md', 'lg', 'full'], default: 'auto' },
|
|
8
16
|
cta: {
|
|
9
17
|
kind: 'object',
|
|
10
18
|
fields: {
|
|
@@ -22,6 +30,19 @@ const css = `
|
|
|
22
30
|
.blk-hero p{font-size:var(--fs-lg);color:var(--muted);margin:0;max-width:60ch}
|
|
23
31
|
.blk-hero .cta{display:inline-block;background:var(--primary);color:var(--on-primary);padding:.7em 1.4em;border-radius:var(--radius);text-decoration:none;font-weight:600;transition:transform var(--motion)}
|
|
24
32
|
.blk-hero .cta:hover{transform:translateY(-1px)}
|
|
33
|
+
/* Image banner */
|
|
34
|
+
.blk-hero.has-image{position:relative;isolation:isolate;color:#fff}
|
|
35
|
+
.blk-hero.has-image .bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-2}
|
|
36
|
+
.blk-hero.has-image .scrim{position:absolute;inset:0;z-index:-1}
|
|
37
|
+
.blk-hero.overlay-dark .scrim{background:rgba(0,0,0,.55)}
|
|
38
|
+
.blk-hero.overlay-scrim .scrim{background:linear-gradient(180deg,rgba(0,0,0,.2),rgba(0,0,0,.6))}
|
|
39
|
+
.blk-hero.overlay-light{color:var(--text)}
|
|
40
|
+
.blk-hero.overlay-light .scrim{background:rgba(255,255,255,.62)}
|
|
41
|
+
.blk-hero.has-image.overlay-none{color:var(--text)}
|
|
42
|
+
.blk-hero.overlay-none .scrim{display:none}
|
|
43
|
+
.blk-hero.has-image .eyebrow,.blk-hero.has-image p{color:inherit;opacity:.9}
|
|
44
|
+
.blk-hero.mh-sm{min-height:36vh}.blk-hero.mh-md{min-height:52vh}.blk-hero.mh-lg{min-height:72vh}.blk-hero.mh-full{min-height:100vh}
|
|
45
|
+
.blk-hero.mh-sm,.blk-hero.mh-md,.blk-hero.mh-lg,.blk-hero.mh-full{display:flex;flex-direction:column;justify-content:center}
|
|
25
46
|
`.trim();
|
|
26
47
|
function render(config) {
|
|
27
48
|
const eyebrow = config.eyebrow;
|
|
@@ -29,20 +50,32 @@ function render(config) {
|
|
|
29
50
|
const subhead = config.subhead;
|
|
30
51
|
const align = config.align;
|
|
31
52
|
const cta = config.cta;
|
|
53
|
+
const imageUrl = sanitizeUrl(config.image);
|
|
54
|
+
const hasImg = imageUrl !== '#' && !!config.image;
|
|
55
|
+
const overlay = ['scrim', 'dark', 'light', 'none'].includes(config.overlay) ? config.overlay : 'scrim';
|
|
56
|
+
const minHeight = ['auto', 'sm', 'md', 'lg', 'full'].includes(config.minHeight) ? config.minHeight : 'auto';
|
|
57
|
+
const classes = ['blk-hero'];
|
|
58
|
+
if (hasImg)
|
|
59
|
+
classes.push('has-image', `overlay-${overlay}`);
|
|
60
|
+
if (minHeight !== 'auto')
|
|
61
|
+
classes.push(`mh-${minHeight}`);
|
|
62
|
+
const banner = hasImg
|
|
63
|
+
? `<img class="bg" src="${escapeAttr(imageUrl)}" alt="" aria-hidden="true">\n <div class="scrim"></div>\n `
|
|
64
|
+
: '';
|
|
32
65
|
const parts = [
|
|
33
66
|
eyebrow ? `<p class="eyebrow">${escapeHtml(eyebrow)}</p>` : '',
|
|
34
67
|
`<h1>${escapeHtml(headline)}</h1>`,
|
|
35
68
|
subhead ? `<p>${escapeHtml(subhead)}</p>` : '',
|
|
36
69
|
cta.label ? `<a class="cta" href="${escapeAttr(sanitizeUrl(cta.href))}">${escapeHtml(cta.label)}</a>` : '',
|
|
37
70
|
].filter(Boolean).join('\n ');
|
|
38
|
-
return `<section class="
|
|
39
|
-
<div class="wrap">
|
|
71
|
+
return `<section class="${classes.join(' ')}" data-align="${escapeAttr(align)}" aria-label="Hero">
|
|
72
|
+
${banner}<div class="wrap">
|
|
40
73
|
${parts}
|
|
41
74
|
</div>
|
|
42
75
|
</section>`;
|
|
43
76
|
}
|
|
44
77
|
export const hero = {
|
|
45
78
|
type: 'hero',
|
|
46
|
-
description: 'Top-of-page
|
|
79
|
+
description: 'Top-of-page hero: a big headline with optional eyebrow, subheading, and CTA — optionally over a full-bleed background image banner with a legibility overlay.',
|
|
47
80
|
schema, css, render,
|
|
48
81
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `profile-header` — the résumé/CV header: avatar, name, role/headline, location,
|
|
3
|
+
* a contact + social row (brand icons), an optional short summary, and optional
|
|
4
|
+
* **Download-PDF** (print) and **Share** action buttons. The buttons are wired by
|
|
5
|
+
* the `resume` island (print → `window.print()` with the engine's print styles;
|
|
6
|
+
* share → Web Share API + copy-link fallback) and are hidden from the printout.
|
|
7
|
+
* Static markup; island only ships when an action button is enabled.
|
|
8
|
+
*/
|
|
9
|
+
import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
|
|
10
|
+
import { BRAND_ICON_PATHS } from './brandIcons.js';
|
|
11
|
+
// Generic (non-brand) contact glyphs — filled, 24×24.
|
|
12
|
+
const GENERIC = {
|
|
13
|
+
email: 'M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v.5l-10 6.25L2 6.5V6zm0 2.85V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8.85l-9.47 5.92a1 1 0 0 1-1.06 0L2 8.85z',
|
|
14
|
+
phone: 'M6.62 2.53c.5-.2 1.08.02 1.36.49l1.7 3.01c.24.42.18.95-.15 1.3L8.06 9.03a12 12 0 0 0 6.9 6.9l1.7-1.47c.35-.33.88-.39 1.3-.15l3.01 1.7c.47.28.69.86.49 1.36l-.98 2.4c-.22.55-.79.9-1.4.83C10.7 20.1 3.9 13.3 3.1 5.9c-.07-.6.28-1.18.83-1.4l2.69-1.97z',
|
|
15
|
+
website: 'M14 3h7v7h-2V6.41l-8.29 8.3-1.42-1.42L17.59 5H14V3zM5 5h5v2H5v12h12v-5h2v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z',
|
|
16
|
+
location: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5A2.5 2.5 0 1 1 12 6.5a2.5 2.5 0 0 1 0 5z',
|
|
17
|
+
};
|
|
18
|
+
const CONTACT_TYPES = ['email', 'phone', 'website', 'location', 'linkedin', 'github', 'twitter', 'x', 'custom'];
|
|
19
|
+
const schema = {
|
|
20
|
+
name: { kind: 'string', required: true, default: '', max: 120 },
|
|
21
|
+
headline: { kind: 'string', default: '', max: 160 }, // role / tagline
|
|
22
|
+
location: { kind: 'string', default: '', max: 120 },
|
|
23
|
+
avatar: { kind: 'string', default: '', max: 500 },
|
|
24
|
+
summary: { kind: 'string', default: '', max: 400 },
|
|
25
|
+
contacts: {
|
|
26
|
+
kind: 'array', max: 10,
|
|
27
|
+
of: {
|
|
28
|
+
kind: 'object',
|
|
29
|
+
fields: {
|
|
30
|
+
type: { kind: 'enum', values: CONTACT_TYPES, default: 'custom' },
|
|
31
|
+
value: { kind: 'string', required: true, default: '', max: 300 }, // email / phone / url / text
|
|
32
|
+
label: { kind: 'string', default: '', max: 80 }, // shown text (defaults to value)
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
showDownload: { kind: 'boolean', default: false },
|
|
37
|
+
downloadLabel: { kind: 'string', default: 'Download PDF', max: 40 },
|
|
38
|
+
showShare: { kind: 'boolean', default: false },
|
|
39
|
+
shareLabel: { kind: 'string', default: 'Share', max: 40 },
|
|
40
|
+
};
|
|
41
|
+
const css = `
|
|
42
|
+
.blk-profile-header{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
43
|
+
.blk-profile-header .wrap{max-width:900px;margin:0 auto;display:flex;gap:calc(var(--space)*1.4);align-items:center;flex-wrap:wrap}
|
|
44
|
+
.blk-profile-header .avatar,.blk-profile-header .initials{width:112px;height:112px;border-radius:999px;flex:0 0 auto;object-fit:cover;display:flex;align-items:center;justify-content:center}
|
|
45
|
+
.blk-profile-header .initials{background:color-mix(in srgb,var(--primary) 18%,var(--surface));color:var(--primary);font-weight:800;font-size:var(--fs-xl)}
|
|
46
|
+
.blk-profile-header .info{flex:1;min-width:min(280px,100%)}
|
|
47
|
+
.blk-profile-header h1{margin:0;font-size:var(--fs-xl);font-weight:800;line-height:1.1}
|
|
48
|
+
.blk-profile-header .role{margin:.2em 0 0;color:var(--primary);font-weight:600;font-size:var(--fs-lg)}
|
|
49
|
+
.blk-profile-header .summary{margin:.5em 0 0;color:var(--muted)}
|
|
50
|
+
.blk-profile-header .contacts{display:flex;flex-wrap:wrap;gap:.4em 1.1em;margin:.8em 0 0}
|
|
51
|
+
.blk-profile-header .contacts a,.blk-profile-header .contacts span.c{display:inline-flex;align-items:center;gap:.4em;color:var(--muted);text-decoration:none;font-size:var(--fs-base)}
|
|
52
|
+
.blk-profile-header .contacts a:hover{color:var(--primary)}
|
|
53
|
+
.blk-profile-header .contacts svg{width:1.05em;height:1.05em;flex:0 0 auto}
|
|
54
|
+
.blk-profile-header .actions{display:flex;gap:.6em;flex-wrap:wrap;margin:calc(var(--space)*1.1) 0 0;flex-basis:100%}
|
|
55
|
+
.blk-profile-header .btn{font:inherit;font-weight:700;cursor:pointer;border:0;border-radius:var(--radius);padding:.6em 1.2em;background:var(--primary);color:var(--on-primary)}
|
|
56
|
+
.blk-profile-header .btn.ghost{background:transparent;color:var(--primary);border:1px solid var(--primary)}
|
|
57
|
+
@media(max-width:560px){.blk-profile-header .wrap{justify-content:center;text-align:center}.blk-profile-header .contacts{justify-content:center}}
|
|
58
|
+
`.trim();
|
|
59
|
+
function svg(path) {
|
|
60
|
+
return `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${path}"></path></svg>`;
|
|
61
|
+
}
|
|
62
|
+
function initials(name) {
|
|
63
|
+
return name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? '').join('').toUpperCase() || '?';
|
|
64
|
+
}
|
|
65
|
+
function contact(c) {
|
|
66
|
+
const type = CONTACT_TYPES.includes(c.type) ? c.type : 'custom';
|
|
67
|
+
const value = c.value;
|
|
68
|
+
if (!value)
|
|
69
|
+
return '';
|
|
70
|
+
const text = escapeHtml(c.label || value);
|
|
71
|
+
const iconPath = type === 'twitter' || type === 'x' ? BRAND_ICON_PATHS.x
|
|
72
|
+
: (BRAND_ICON_PATHS[type] ?? GENERIC[type]);
|
|
73
|
+
const icon = iconPath ? svg(iconPath) : '';
|
|
74
|
+
if (type === 'location')
|
|
75
|
+
return `<span class="c">${icon}${text}</span>`;
|
|
76
|
+
let href = value;
|
|
77
|
+
if (type === 'email')
|
|
78
|
+
href = `mailto:${value}`;
|
|
79
|
+
else if (type === 'phone')
|
|
80
|
+
href = `tel:${value.replace(/[^0-9+]/g, '')}`;
|
|
81
|
+
const safe = sanitizeUrl(href);
|
|
82
|
+
if (safe === '#')
|
|
83
|
+
return `<span class="c">${icon}${text}</span>`;
|
|
84
|
+
return `<a href="${escapeAttr(safe)}" rel="noopener">${icon}${text}</a>`;
|
|
85
|
+
}
|
|
86
|
+
function render(config) {
|
|
87
|
+
const name = config.name;
|
|
88
|
+
const headline = config.headline;
|
|
89
|
+
const summary = config.summary;
|
|
90
|
+
const avatarUrl = sanitizeUrl(config.avatar);
|
|
91
|
+
const hasAvatar = avatarUrl !== '#' && !!config.avatar;
|
|
92
|
+
const contacts = (config.contacts ?? []).map(contact).filter(Boolean);
|
|
93
|
+
const showDownload = config.showDownload === true;
|
|
94
|
+
const showShare = config.showShare === true;
|
|
95
|
+
const avatar = hasAvatar
|
|
96
|
+
? `<img class="avatar" src="${escapeAttr(avatarUrl)}" alt="${escapeAttr(name)}">`
|
|
97
|
+
: `<div class="initials" aria-hidden="true">${escapeHtml(initials(name))}</div>`;
|
|
98
|
+
const actions = (showDownload || showShare)
|
|
99
|
+
? `<div class="actions" data-wl-noprint>
|
|
100
|
+
${showDownload ? `<button class="btn" type="button" data-wl-print>${escapeHtml(config.downloadLabel || 'Download PDF')}</button>` : ''}
|
|
101
|
+
${showShare ? `<button class="btn ghost" type="button" data-wl-share>${escapeHtml(config.shareLabel || 'Share')}</button>` : ''}
|
|
102
|
+
</div>`
|
|
103
|
+
: '';
|
|
104
|
+
return `<section class="blk-profile-header" aria-label="${escapeAttr(name || 'Profile')}">
|
|
105
|
+
<div class="wrap">
|
|
106
|
+
${avatar}
|
|
107
|
+
<div class="info">
|
|
108
|
+
<h1>${escapeHtml(name)}</h1>
|
|
109
|
+
${headline ? `<p class="role">${escapeHtml(headline)}</p>` : ''}
|
|
110
|
+
${summary ? `<p class="summary">${escapeHtml(summary)}</p>` : ''}
|
|
111
|
+
${contacts.length ? `<div class="contacts">${contacts.join('')}</div>` : ''}
|
|
112
|
+
</div>
|
|
113
|
+
${actions}
|
|
114
|
+
</div>
|
|
115
|
+
</section>`;
|
|
116
|
+
}
|
|
117
|
+
export const profileHeader = {
|
|
118
|
+
type: 'profile-header',
|
|
119
|
+
description: 'A résumé/CV header: avatar, name, role, location, a contact + social row, and optional Download-PDF and Share buttons.',
|
|
120
|
+
schema,
|
|
121
|
+
css,
|
|
122
|
+
render,
|
|
123
|
+
island: 'resume',
|
|
124
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skills` — grouped skills shown as **tags** (chips, with optional proficiency
|
|
3
|
+
* dots) or **bars** (labelled level bars). Doubles for Languages, Tools, etc.
|
|
4
|
+
* Static brick.
|
|
5
|
+
*/
|
|
6
|
+
import { escapeAttr, escapeHtml } from '../schema.js';
|
|
7
|
+
const schema = {
|
|
8
|
+
title: { kind: 'string', default: 'Skills', max: 120 },
|
|
9
|
+
display: { kind: 'enum', values: ['tags', 'bars'], default: 'tags' },
|
|
10
|
+
groups: {
|
|
11
|
+
kind: 'array', max: 10,
|
|
12
|
+
of: {
|
|
13
|
+
kind: 'object',
|
|
14
|
+
fields: {
|
|
15
|
+
name: { kind: 'string', default: '', max: 60 }, // e.g. "Languages"
|
|
16
|
+
items: {
|
|
17
|
+
kind: 'array', max: 40,
|
|
18
|
+
of: {
|
|
19
|
+
kind: 'object',
|
|
20
|
+
fields: {
|
|
21
|
+
label: { kind: 'string', required: true, default: '', max: 60 },
|
|
22
|
+
level: { kind: 'int', min: 0, max: 5, default: 0 }, // 0 = unrated
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const css = `
|
|
31
|
+
.blk-skills{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
|
|
32
|
+
.blk-skills .wrap{max-width:820px;margin:0 auto}
|
|
33
|
+
.blk-skills h2{font-size:var(--fs-xl);margin:0 0 var(--space);font-weight:800}
|
|
34
|
+
.blk-skills .group{margin-bottom:calc(var(--space)*1.1)}
|
|
35
|
+
.blk-skills .group:last-child{margin-bottom:0}
|
|
36
|
+
.blk-skills .gname{font-weight:700;font-size:var(--fs-base);margin:0 0 .5em;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
37
|
+
.blk-skills .tags{display:flex;flex-wrap:wrap;gap:.5em}
|
|
38
|
+
.blk-skills .tag{display:inline-flex;align-items:center;gap:.5em;padding:.4em .8em;border-radius:999px;background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 14%,transparent);font-size:var(--fs-base);font-weight:600}
|
|
39
|
+
.blk-skills .dots{display:inline-flex;gap:2px}
|
|
40
|
+
.blk-skills .dots i{width:6px;height:6px;border-radius:999px;background:color-mix(in srgb,var(--text) 25%,transparent)}
|
|
41
|
+
.blk-skills .dots i.on{background:var(--primary)}
|
|
42
|
+
.blk-skills .bars{display:grid;gap:.6em}
|
|
43
|
+
.blk-skills .bar{display:grid;grid-template-columns:minmax(90px,180px) 1fr;gap:.8em;align-items:center}
|
|
44
|
+
.blk-skills .bar .blabel{font-weight:600;font-size:var(--fs-base)}
|
|
45
|
+
.blk-skills .track{height:8px;border-radius:999px;background:color-mix(in srgb,var(--text) 12%,transparent);overflow:hidden}
|
|
46
|
+
.blk-skills .fill{height:100%;background:var(--primary);border-radius:999px}
|
|
47
|
+
@media(max-width:480px){.blk-skills .bar{grid-template-columns:1fr}}
|
|
48
|
+
`.trim();
|
|
49
|
+
function dots(level) {
|
|
50
|
+
const n = Math.max(0, Math.min(5, level | 0));
|
|
51
|
+
if (!n)
|
|
52
|
+
return '';
|
|
53
|
+
return `<span class="dots" aria-hidden="true">${Array.from({ length: 5 }, (_v, i) => `<i class="${i < n ? 'on' : ''}"></i>`).join('')}</span>`;
|
|
54
|
+
}
|
|
55
|
+
function group(g, display) {
|
|
56
|
+
const items = (g.items ?? []).filter((s) => s && s.label);
|
|
57
|
+
if (!items.length)
|
|
58
|
+
return '';
|
|
59
|
+
let body;
|
|
60
|
+
if (display === 'bars') {
|
|
61
|
+
body = `<div class="bars">${items.map((s) => {
|
|
62
|
+
const pct = Math.max(0, Math.min(5, s.level | 0)) * 20;
|
|
63
|
+
return `<div class="bar"><span class="blabel">${escapeHtml(s.label)}</span><span class="track"><span class="fill" style="width:${pct}%"></span></span></div>`;
|
|
64
|
+
}).join('')}</div>`;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
body = `<div class="tags">${items.map((s) => `<span class="tag">${escapeHtml(s.label)}${dots(s.level)}</span>`).join('')}</div>`;
|
|
68
|
+
}
|
|
69
|
+
return `<div class="group">
|
|
70
|
+
${g.name ? `<p class="gname">${escapeHtml(g.name)}</p>` : ''}
|
|
71
|
+
${body}
|
|
72
|
+
</div>`;
|
|
73
|
+
}
|
|
74
|
+
function render(config) {
|
|
75
|
+
const title = config.title;
|
|
76
|
+
const display = config.display === 'bars' ? 'bars' : 'tags';
|
|
77
|
+
const groups = (config.groups ?? []).map((g) => group(g, display)).filter(Boolean);
|
|
78
|
+
return `<section class="blk-skills" aria-label="${escapeAttr(title || 'Skills')}">
|
|
79
|
+
<div class="wrap">
|
|
80
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
81
|
+
${groups.join('\n ')}
|
|
82
|
+
</div>
|
|
83
|
+
</section>`;
|
|
84
|
+
}
|
|
85
|
+
export const skills = {
|
|
86
|
+
type: 'skills',
|
|
87
|
+
description: 'Grouped skills shown as tags (with optional proficiency dots) or labelled level bars; also for languages and tools.',
|
|
88
|
+
schema,
|
|
89
|
+
css,
|
|
90
|
+
render,
|
|
91
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resume` island — wires the `profile-header` action buttons.
|
|
3
|
+
*
|
|
4
|
+
* Shipped from `@bytesbrains/weblocks/islands/resume.js`; served at the island
|
|
5
|
+
* URL the renderer emits (default `/_island/resume.js`). Zero dependencies,
|
|
6
|
+
* self-executing, idempotent, guarded by `typeof document`.
|
|
7
|
+
*
|
|
8
|
+
* - `[data-wl-print]` → `window.print()`. The engine's `@media print` styles turn
|
|
9
|
+
* the page into a clean one-column résumé, so "Save as PDF" in the print dialog
|
|
10
|
+
* produces the downloadable CV.
|
|
11
|
+
* - `[data-wl-share]` → the Web Share API (native share sheet) with a
|
|
12
|
+
* copy-link-to-clipboard fallback.
|
|
13
|
+
*/
|
|
14
|
+
if (typeof document !== 'undefined') {
|
|
15
|
+
const ready = (fn) => document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', fn) : fn();
|
|
16
|
+
ready(() => {
|
|
17
|
+
document.querySelectorAll('[data-wl-print]').forEach((btn) => {
|
|
18
|
+
btn.addEventListener('click', () => window.print());
|
|
19
|
+
});
|
|
20
|
+
document.querySelectorAll('[data-wl-share]').forEach((btn) => {
|
|
21
|
+
btn.addEventListener('click', async () => {
|
|
22
|
+
const url = location.href;
|
|
23
|
+
const nav = navigator;
|
|
24
|
+
if (typeof nav.share === 'function') {
|
|
25
|
+
try {
|
|
26
|
+
await nav.share({ title: document.title, url });
|
|
27
|
+
}
|
|
28
|
+
catch { /* dismissed */ }
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(url);
|
|
33
|
+
const prev = btn.textContent;
|
|
34
|
+
btn.textContent = 'Link copied';
|
|
35
|
+
setTimeout(() => { btn.textContent = prev; }, 1800);
|
|
36
|
+
}
|
|
37
|
+
catch { /* clipboard blocked */ }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export {};
|
package/lib/registry.js
CHANGED
|
@@ -2,6 +2,9 @@ import { nav } from './blocks/nav.js';
|
|
|
2
2
|
import { announcementBar } from './blocks/announcementBar.js';
|
|
3
3
|
import { hero } from './blocks/hero.js';
|
|
4
4
|
import { heroApp } from './blocks/heroApp.js';
|
|
5
|
+
import { profileHeader } from './blocks/profileHeader.js';
|
|
6
|
+
import { experience } from './blocks/experience.js';
|
|
7
|
+
import { skills } from './blocks/skills.js';
|
|
5
8
|
import { features } from './blocks/features.js';
|
|
6
9
|
import { about } from './blocks/about.js';
|
|
7
10
|
import { richText } from './blocks/richText.js';
|
|
@@ -47,6 +50,8 @@ const SPECS = [
|
|
|
47
50
|
appShell, nav, announcementBar, sidebar,
|
|
48
51
|
// heroes
|
|
49
52
|
hero, heroApp,
|
|
53
|
+
// résumé / profile
|
|
54
|
+
profileHeader, experience, skills,
|
|
50
55
|
// content
|
|
51
56
|
features, about, richText, split, steps, stats,
|
|
52
57
|
services, pricing, logos, team,
|
|
@@ -82,5 +87,8 @@ export function needsIsland(spec, config) {
|
|
|
82
87
|
return false;
|
|
83
88
|
if (spec.type === 'gallery')
|
|
84
89
|
return config.lightbox === true;
|
|
90
|
+
// profile-header only needs the resume island when an action button is shown.
|
|
91
|
+
if (spec.type === 'profile-header')
|
|
92
|
+
return config.showDownload === true || config.showShare === true;
|
|
85
93
|
return true;
|
|
86
94
|
}
|
package/lib/render.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* options and speak the §6 contract; with the default no-op runtime they render
|
|
10
10
|
* inert-but-valid. PWA/SEO head tags are emitted only when the manifest opts in.
|
|
11
11
|
*/
|
|
12
|
-
import { escapeAttr, escapeHtml, parse } from './schema.js';
|
|
12
|
+
import { escapeAttr, escapeHtml, parse, sanitizeUrl } from './schema.js';
|
|
13
13
|
import { getSpec, needsIsland, REGISTRY } from './registry.js';
|
|
14
14
|
import { normalizeTokens, sectionOverrideCss, tokensToCss } from './tokens.js';
|
|
15
15
|
import { NOOP_RUNTIME } from './runtime.js';
|
|
@@ -20,6 +20,10 @@ img{max-width:100%}
|
|
|
20
20
|
.blk-scope{display:block}
|
|
21
21
|
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
22
22
|
`.trim().replace(/\n/g, '');
|
|
23
|
+
// Print styles — makes any page (especially a résumé) export cleanly to PDF via
|
|
24
|
+
// the browser's print dialog: keep colours, hide `data-wl-noprint` controls,
|
|
25
|
+
// un-stick fixed chrome, and avoid splitting entries across pages.
|
|
26
|
+
const PRINT_CSS = `@media print{*{-webkit-print-color-adjust:exact;print-color-adjust:exact}[data-wl-noprint]{display:none!important}.blk-nav,.blk-app-shell,.blk-announcement-bar,.blk-sidebar{position:static!important}.blk-profile-header,.blk-experience .entry,.blk-skills .group,.blk-timeline li{break-inside:avoid}a[href]{text-decoration:none}@page{margin:1.4cm}}`;
|
|
23
27
|
/** Render one block: normalize its config, then hand markup to the brick. */
|
|
24
28
|
function renderBlock(block, manifest, runtime) {
|
|
25
29
|
const spec = getSpec(block.type);
|
|
@@ -67,13 +71,26 @@ export function renderSite(manifest, options = {}) {
|
|
|
67
71
|
<meta charset="utf-8">
|
|
68
72
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
69
73
|
<title>${escapeHtml(meta.title || 'Untitled site')}</title>${meta.description ? `\n<meta name="description" content="${escapeAttr(meta.description)}">` : ''}${headExtras(manifest, tokens.palette.primary)}
|
|
70
|
-
<style>${tokensToCss(tokens)}\n${RESET_CSS}\n${blockCss}</style>
|
|
74
|
+
<style>${tokensToCss(tokens)}\n${RESET_CSS}\n${blockCss}\n${PRINT_CSS}</style>
|
|
71
75
|
</head>
|
|
72
76
|
<body>
|
|
73
77
|
${body}${islandTags ? '\n' + islandTags : ''}
|
|
74
78
|
</body>
|
|
75
79
|
</html>`;
|
|
76
80
|
}
|
|
81
|
+
/** A browser-tab favicon link from meta.favicon (emoji → data URI, else a URL). */
|
|
82
|
+
function faviconTag(fav) {
|
|
83
|
+
const s = String(fav ?? '').trim();
|
|
84
|
+
if (!s)
|
|
85
|
+
return '';
|
|
86
|
+
// A short glyph with no scheme/slash/dot → treat as an emoji favicon.
|
|
87
|
+
if ([...s].length <= 2 && !/[/:.]/.test(s)) {
|
|
88
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">${escapeHtml(s)}</text></svg>`;
|
|
89
|
+
return `<link rel="icon" href="data:image/svg+xml,${encodeURIComponent(svg)}">`;
|
|
90
|
+
}
|
|
91
|
+
const url = sanitizeUrl(s);
|
|
92
|
+
return url === '#' ? '' : `<link rel="icon" href="${escapeAttr(url)}">`;
|
|
93
|
+
}
|
|
77
94
|
/** SEO + PWA <head> tags, emitted only when the manifest opts in. */
|
|
78
95
|
function headExtras(manifest, primary) {
|
|
79
96
|
const out = [];
|
|
@@ -81,6 +98,9 @@ function headExtras(manifest, primary) {
|
|
|
81
98
|
const pwa = manifest?.pwa;
|
|
82
99
|
const title = manifest?.meta?.title ?? '';
|
|
83
100
|
const desc = manifest?.meta?.description ?? '';
|
|
101
|
+
const favicon = faviconTag(manifest?.meta?.favicon);
|
|
102
|
+
if (favicon)
|
|
103
|
+
out.push(favicon);
|
|
84
104
|
if (seo) {
|
|
85
105
|
if (seo.canonical)
|
|
86
106
|
out.push(`<link rel="canonical" href="${escapeAttr(seo.canonical)}">`);
|
package/lib/types.d.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface SiteMeta {
|
|
|
62
62
|
title: string;
|
|
63
63
|
description: string;
|
|
64
64
|
lang: string;
|
|
65
|
+
/**
|
|
66
|
+
* Optional browser-tab favicon: a URL to an icon (svg/png/ico), OR a single
|
|
67
|
+
* emoji/glyph (rendered as an inline SVG data URI). Absent = no icon link.
|
|
68
|
+
*/
|
|
69
|
+
favicon?: string;
|
|
65
70
|
}
|
|
66
71
|
/**
|
|
67
72
|
* Optional PWA descriptor. When present the engine can emit a Web App Manifest
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytesbrains/weblocks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Block engine for AI-composable web apps — the AI composes a SiteManifest from a fixed block catalog; the engine validates and renders it to static HTML. Snap-together \"Lego\" web building blocks, shareable across repos.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"lib",
|
|
38
38
|
"!lib/**/*.test.js",
|
|
39
39
|
"!lib/**/*.test.d.ts",
|
|
40
|
-
"!lib/example
|
|
40
|
+
"!lib/example*",
|
|
41
41
|
"catalog.json",
|
|
42
42
|
"CATALOG.md",
|
|
43
43
|
"AGENT.md",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"emit:catalog": "node scripts/emit-catalog.mjs",
|
|
62
62
|
"test": "npm run build && node --test lib/*.test.js test/*.mjs",
|
|
63
63
|
"example": "tsc -p tsconfig.json && node lib/example.js",
|
|
64
|
+
"example:resume": "tsc -p tsconfig.json && node lib/example-resume.js",
|
|
64
65
|
"ai": "tsc -p tsconfig.json && node scripts/ai-run.mjs",
|
|
65
66
|
"prepare": "npm run build",
|
|
66
67
|
"prepublishOnly": "npm run build && npm run emit:catalog && npm test"
|