@bytesbrains/weblocks 0.4.0 → 0.6.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.
@@ -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
+ };
@@ -44,8 +44,9 @@ function figure(it) {
44
44
  function render(config) {
45
45
  const columns = Number.isInteger(config.columns) ? config.columns : 3;
46
46
  const gap = GAP_REM[config.gap] ?? GAP_REM.md;
47
+ const lightbox = config.lightbox === true; // flag for the `lightbox` island
47
48
  const items = (config.items ?? []).map(figure).filter(Boolean);
48
- return `<section class="blk-gallery" aria-label="Gallery">
49
+ return `<section class="blk-gallery" aria-label="Gallery"${lightbox ? ' data-wl-lightbox="true"' : ''}>
49
50
  <div class="wrap">
50
51
  <div class="grid" style="--cols:${columns};--gap:${gap}">
51
52
  ${items.join('\n ')}
@@ -1,10 +1,18 @@
1
- /** `hero` — headline + optional eyebrow/subhead/CTA. Static brick. */
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="blk-hero" data-align="${escapeAttr(align)}" aria-label="Hero">
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 banner: a big headline with an optional eyebrow, subheading, and one call-to-action button.',
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,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const profileHeader: BlockSpec;
@@ -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,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const skills: BlockSpec;
@@ -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
+ };
@@ -1,44 +1,94 @@
1
- /** `social-links` — a centered icon row linking out to profiles. Static brick. */
1
+ /**
2
+ * `social-links` — one row/grid of links to social or contact profiles.
3
+ *
4
+ * A single generic block (not one per network): you add only the accounts you
5
+ * have, so unset ones are simply absent. Each link's `platform` selects a
6
+ * built-in monochrome brand icon (from simple-icons, themed via `currentColor`)
7
+ * and a default accessible label; `custom` (or an unknown platform) falls back to
8
+ * your own emoji/glyph `icon`. Layout adapts via `layout` (row/grid) and
9
+ * `variant` (labeled/icon-only). Static brick.
10
+ */
2
11
  import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
12
+ import { BRAND_ICON_PATHS } from './brandIcons.js';
13
+ const PLATFORMS = [
14
+ 'x', 'twitter', 'instagram', 'facebook', 'linkedin', 'youtube', 'github', 'tiktok',
15
+ 'whatsapp', 'telegram', 'discord', 'mastodon', 'rss', 'email', 'website', 'phone', 'custom',
16
+ ];
17
+ /** Default accessible label per platform (author `label` overrides). */
18
+ const LABELS = {
19
+ x: 'X', twitter: 'X', instagram: 'Instagram', facebook: 'Facebook', linkedin: 'LinkedIn',
20
+ youtube: 'YouTube', github: 'GitHub', tiktok: 'TikTok', whatsapp: 'WhatsApp',
21
+ telegram: 'Telegram', discord: 'Discord', mastodon: 'Mastodon', rss: 'RSS',
22
+ email: 'Email', website: 'Website', phone: 'Phone',
23
+ };
24
+ /** Simple monochrome (non-brand) contact icons — filled, 24×24. */
25
+ const GENERIC_ICONS = {
26
+ 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',
27
+ 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',
28
+ 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',
29
+ };
3
30
  const schema = {
4
31
  title: { kind: 'string', default: '', max: 120 },
32
+ layout: { kind: 'enum', values: ['row', 'grid'], default: 'row' },
33
+ variant: { kind: 'enum', values: ['labeled', 'icon'], default: 'labeled' },
34
+ align: { kind: 'enum', values: ['start', 'center', 'end'], default: 'center' },
5
35
  links: {
6
- kind: 'array', max: 12,
36
+ kind: 'array', max: 20,
7
37
  of: {
8
38
  kind: 'object',
9
39
  fields: {
10
- label: { kind: 'string', required: true, default: '', max: 40 },
40
+ platform: { kind: 'enum', values: PLATFORMS, default: 'custom' },
11
41
  href: { kind: 'string', required: true, default: '', max: 500 },
12
- icon: { kind: 'string', default: '', max: 8 }, // emoji or a single glyph
42
+ label: { kind: 'string', default: '', max: 60 }, // overrides the platform default
43
+ icon: { kind: 'string', default: '', max: 8 }, // emoji/glyph for `custom`
13
44
  },
14
45
  },
15
46
  },
16
47
  };
17
48
  const css = `
18
- .blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text);text-align:center}
19
- .blk-social-links .wrap{max-width:800px;margin:0 auto}
20
- .blk-social-links h2{font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
21
- .blk-social-links .row{display:flex;flex-wrap:wrap;justify-content:center;gap:.8em}
22
- .blk-social-links a{display:inline-flex;align-items:center;gap:.4em;padding:.5em .9em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 15%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion)}
23
- .blk-social-links a:hover,.blk-social-links a:focus-visible{color:var(--primary);border-color:var(--primary)}
24
- .blk-social-links .ico{font-size:1.15em;line-height:1}
49
+ .blk-social-links{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
50
+ .blk-social-links .wrap{max-width:900px;margin:0 auto}
51
+ .blk-social-links h2{text-align:center;font-size:var(--fs-lg);font-weight:700;margin:0 0 var(--space)}
52
+ .blk-social-links .items{display:flex;flex-wrap:wrap;gap:.7em}
53
+ .blk-social-links.layout-grid .items{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
54
+ .blk-social-links.align-center .items{justify-content:center}
55
+ .blk-social-links.align-end .items{justify-content:flex-end}
56
+ .blk-social-links .s-item{display:inline-flex;align-items:center;justify-content:center;gap:.5em;padding:.55em .95em;border-radius:var(--radius);text-decoration:none;color:var(--text);background:var(--surface);border:1px solid color-mix(in srgb,var(--text) 14%,transparent);font-size:var(--fs-base);font-weight:600;transition:color var(--motion),border-color var(--motion),transform var(--motion)}
57
+ .blk-social-links .s-item:hover,.blk-social-links .s-item:focus-visible{color:var(--primary);border-color:var(--primary);transform:translateY(-1px)}
58
+ .blk-social-links .s-item.icon-only{padding:0;width:2.9em;height:2.9em;border-radius:999px}
59
+ .blk-social-links .ico{flex:0 0 auto;width:1.2em;height:1.2em;display:block}
60
+ .blk-social-links .ico.emoji{font-size:1.15em;line-height:1;width:auto;height:auto}
25
61
  `.trim();
26
- function link(l) {
62
+ function iconMarkup(platform, emoji) {
63
+ const path = platform === 'twitter' ? BRAND_ICON_PATHS.x : (BRAND_ICON_PATHS[platform] ?? GENERIC_ICONS[platform]);
64
+ if (path)
65
+ return `<svg class="ico" viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="${path}"></path></svg>`;
66
+ return emoji ? `<span class="ico emoji" aria-hidden="true">${escapeHtml(emoji)}</span>` : '';
67
+ }
68
+ function item(l, iconOnly) {
27
69
  const href = sanitizeUrl(l.href);
28
- if (!l.label || href === '#' || !l.href)
29
- return '';
30
- return `<a href="${escapeAttr(href)}" rel="noopener">
31
- ${l.icon ? `<span class="ico" aria-hidden="true">${escapeHtml(l.icon)}</span>` : ''}
32
- <span>${escapeHtml(l.label)}</span>
70
+ if (href === '#' || !l.href)
71
+ return ''; // no destination → drop (unset stays hidden)
72
+ const platform = String(l.platform || 'custom');
73
+ const label = l.label || LABELS[platform] || 'Link';
74
+ const icon = iconMarkup(platform, l.icon);
75
+ // icon-only mode needs the label as the accessible name; labeled mode shows it.
76
+ return `<a class="s-item${iconOnly ? ' icon-only' : ''}" href="${escapeAttr(href)}" rel="noopener"${iconOnly ? ` aria-label="${escapeAttr(label)}"` : ''}>
77
+ ${icon}
78
+ ${iconOnly ? '' : `<span class="lbl">${escapeHtml(label)}</span>`}
33
79
  </a>`;
34
80
  }
35
81
  function render(config) {
36
82
  const title = config.title;
37
- const links = (config.links ?? []).map(link).filter(Boolean);
38
- return `<section class="blk-social-links" aria-label="${escapeAttr(title || 'Social links')}">
83
+ const layout = config.layout === 'grid' ? 'grid' : 'row';
84
+ const variant = config.variant === 'icon' ? 'icon' : 'labeled';
85
+ const align = ['start', 'center', 'end'].includes(config.align) ? config.align : 'center';
86
+ const iconOnly = variant === 'icon';
87
+ const links = (config.links ?? []).map((l) => item(l, iconOnly)).filter(Boolean);
88
+ return `<section class="blk-social-links layout-${layout} align-${align}" aria-label="${escapeAttr(title || 'Social links')}">
39
89
  <div class="wrap">
40
90
  ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
41
- <div class="row">
91
+ <div class="items">
42
92
  ${links.join('\n ')}
43
93
  </div>
44
94
  </div>
@@ -46,7 +96,7 @@ function render(config) {
46
96
  }
47
97
  export const socialLinks = {
48
98
  type: 'social-links',
49
- description: 'A centered row of links to social or external profiles, each an optional icon plus a label.',
99
+ description: 'A row or grid of links to social/contact profiles; each link picks a built-in brand icon by platform (or a custom emoji), shown labeled or icon-only.',
50
100
  schema,
51
101
  css,
52
102
  render,
@@ -0,0 +1,2 @@
1
+ import type { BlockSpec } from '../registry.js';
2
+ export declare const videoGallery: BlockSpec;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `video-gallery` — a grid or carousel of **click-to-play video cards**
3
+ * (YouTube / Vimeo / self-hosted). Each card is a lightweight *facade*: a
4
+ * thumbnail + play button, so no heavy player iframe loads until the visitor
5
+ * presses play — then the `video` island swaps in the real player inline.
6
+ *
7
+ * Progressive enhancement: with no JavaScript each card is a link that opens the
8
+ * video on its platform; with the island, it plays inline. YouTube thumbnails are
9
+ * derived from the id automatically (give a `poster` for Vimeo/file). Static-first
10
+ * brick — the island only adds inline playback.
11
+ */
12
+ import { escapeAttr, escapeHtml, sanitizeUrl } from '../schema.js';
13
+ import { youtubeId, vimeoId, youtubeThumb, youtubeEmbed, youtubeWatch, vimeoEmbed, vimeoWatch, PLAY_ICON, } from './videoUtil.js';
14
+ const schema = {
15
+ title: { kind: 'string', default: '', max: 120 },
16
+ layout: { kind: 'enum', values: ['grid', 'carousel'], default: 'grid' },
17
+ columns: { kind: 'int', oneOf: [2, 3, 4], default: 3 },
18
+ items: {
19
+ kind: 'array', max: 24,
20
+ of: {
21
+ kind: 'object',
22
+ fields: {
23
+ provider: { kind: 'enum', values: ['youtube', 'vimeo', 'file'], default: 'youtube' },
24
+ src: { kind: 'string', required: true, default: '', max: 500 }, // id or url
25
+ title: { kind: 'string', default: '', max: 160 },
26
+ poster: { kind: 'string', default: '', max: 500 }, // thumbnail (auto for youtube)
27
+ },
28
+ },
29
+ },
30
+ };
31
+ const css = `
32
+ .blk-video-gallery{padding:var(--space-lg) var(--space);background:var(--bg);color:var(--text)}
33
+ .blk-video-gallery .wrap{max-width:1120px;margin:0 auto}
34
+ .blk-video-gallery h2{font-size:var(--fs-xl);margin:0 0 var(--space-lg);font-weight:800;text-align:center}
35
+ .blk-video-gallery .grid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:calc(var(--space)*1.2)}
36
+ .blk-video-gallery.layout-carousel .grid{display:flex;grid-template-columns:none;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:.5em}
37
+ .blk-video-gallery.layout-carousel .v-card{flex:0 0 min(88%,440px);scroll-snap-align:center}
38
+ .blk-video-gallery .v-card{display:block;text-decoration:none;color:inherit;background:var(--surface);border-radius:var(--radius);overflow:hidden;transition:transform var(--motion)}
39
+ .blk-video-gallery .v-card:hover{transform:translateY(-3px)}
40
+ .blk-video-gallery .v-media{position:relative;display:block;aspect-ratio:16/9;background:#000}
41
+ .blk-video-gallery .v-media img{width:100%;height:100%;object-fit:cover;display:block}
42
+ .blk-video-gallery .v-play{position:absolute;inset:0;margin:auto;width:62px;height:62px;display:flex;align-items:center;justify-content:center;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;transition:background var(--motion),transform var(--motion)}
43
+ .blk-video-gallery .v-card:hover .v-play{background:var(--primary);color:var(--on-primary);transform:scale(1.06)}
44
+ .blk-video-gallery .v-play svg{margin-left:3px}
45
+ .blk-video-gallery .v-title{display:block;padding:.7em .9em;font-weight:600;font-size:var(--fs-base)}
46
+ .blk-video-gallery .v-frame{position:absolute;inset:0;width:100%;height:100%;border:0}
47
+ @media(max-width:720px){.blk-video-gallery .grid{grid-template-columns:1fr 1fr}}
48
+ @media(max-width:440px){.blk-video-gallery .grid{grid-template-columns:1fr}}
49
+ `.trim();
50
+ function card(it) {
51
+ const provider = ['youtube', 'vimeo', 'file'].includes(it.provider) ? it.provider : 'youtube';
52
+ const title = it.title;
53
+ const posterUrl = sanitizeUrl(it.poster);
54
+ const poster = (posterUrl !== '#' && it.poster) ? posterUrl : '';
55
+ let thumb = '', embed = '', watch = '';
56
+ if (provider === 'youtube') {
57
+ const id = youtubeId(it.src);
58
+ if (!id)
59
+ return '';
60
+ thumb = poster || youtubeThumb(id);
61
+ embed = youtubeEmbed(id, true);
62
+ watch = youtubeWatch(id);
63
+ }
64
+ else if (provider === 'vimeo') {
65
+ const id = vimeoId(it.src);
66
+ if (!id)
67
+ return '';
68
+ thumb = poster;
69
+ embed = vimeoEmbed(id, true);
70
+ watch = vimeoWatch(id);
71
+ }
72
+ else {
73
+ const url = sanitizeUrl(it.src);
74
+ if (url === '#' || !it.src)
75
+ return '';
76
+ thumb = poster;
77
+ embed = url;
78
+ watch = url;
79
+ }
80
+ const media = `<span class="v-media">
81
+ ${thumb ? `<img src="${escapeAttr(thumb)}" alt="${escapeAttr(title)}" loading="lazy">` : ''}
82
+ <span class="v-play" aria-hidden="true">${PLAY_ICON}</span>
83
+ </span>`;
84
+ return `<a class="v-card" href="${escapeAttr(watch)}" target="_blank" rel="noopener" data-wl-video data-wl-provider="${provider}" data-wl-embed="${escapeAttr(embed)}" data-wl-title="${escapeAttr(title || 'Video')}" aria-label="Play video: ${escapeAttr(title || 'video')}">
85
+ ${media}
86
+ ${title ? `<span class="v-title">${escapeHtml(title)}</span>` : ''}
87
+ </a>`;
88
+ }
89
+ function render(config) {
90
+ const title = config.title;
91
+ const layout = config.layout === 'carousel' ? 'carousel' : 'grid';
92
+ const columns = Number.isInteger(config.columns) ? config.columns : 3;
93
+ const items = (config.items ?? []).map(card).filter(Boolean);
94
+ return `<section class="blk-video-gallery layout-${layout}" aria-label="${escapeAttr(title || 'Videos')}">
95
+ <div class="wrap">
96
+ ${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
97
+ <div class="grid" style="--cols:${columns}">
98
+ ${items.join('\n ')}
99
+ </div>
100
+ </div>
101
+ </section>`;
102
+ }
103
+ export const videoGallery = {
104
+ type: 'video-gallery',
105
+ description: 'A grid or carousel of click-to-play video cards (YouTube/Vimeo/file); each loads its player inline on click (a lightweight facade — no heavy iframes up front).',
106
+ schema,
107
+ css,
108
+ render,
109
+ island: 'video',
110
+ };