@profpowell/vb-design-system 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/vb-design-system.css +993 -0
- package/dist/vb-design-system.js +4482 -0
- package/dist/vb-design-system.min.css +1 -0
- package/dist/vb-design-system.min.js +513 -0
- package/package.json +78 -0
- package/src/index.css +21 -0
- package/src/index.js +24 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/vb-element.js +96 -0
- package/src/patterns/do-dont.css +86 -0
- package/src/patterns/token-table.css +67 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/accessibility-specimen/api.json +51 -0
- package/src/web-components/accessibility-specimen/logic.js +232 -0
- package/src/web-components/accessibility-specimen/static.html +41 -0
- package/src/web-components/accessibility-specimen/styles.css +156 -0
- package/src/web-components/brand-specimen/api.json +37 -0
- package/src/web-components/brand-specimen/logic.js +61 -0
- package/src/web-components/brand-specimen/static.html +77 -0
- package/src/web-components/brand-specimen/styles.css +10 -0
- package/src/web-components/breakpoint-specimen/api.json +44 -0
- package/src/web-components/breakpoint-specimen/logic.js +186 -0
- package/src/web-components/breakpoint-specimen/static.html +38 -0
- package/src/web-components/breakpoint-specimen/styles.css +180 -0
- package/src/web-components/color-palette/api.json +73 -0
- package/src/web-components/color-palette/logic.js +280 -0
- package/src/web-components/color-palette/static.html +54 -0
- package/src/web-components/color-palette/styles.css +113 -0
- package/src/web-components/color-picker/_color-utils.js +210 -0
- package/src/web-components/component-sampler/api.json +15 -0
- package/src/web-components/component-sampler/logic.js +120 -0
- package/src/web-components/component-sampler/static.html +47 -0
- package/src/web-components/component-sampler/styles.css +9 -0
- package/src/web-components/font-pairer/_font-data.js +95 -0
- package/src/web-components/font-pairer/api.json +56 -0
- package/src/web-components/font-pairer/logic.js +401 -0
- package/src/web-components/font-pairer/static.html +9 -0
- package/src/web-components/font-pairer/styles.css +10 -0
- package/src/web-components/gradient-builder/_gradient-utils.js +54 -0
- package/src/web-components/gradient-builder/api.json +63 -0
- package/src/web-components/gradient-builder/logic.js +261 -0
- package/src/web-components/gradient-builder/static.html +9 -0
- package/src/web-components/gradient-builder/styles.css +10 -0
- package/src/web-components/icon-set/api.json +39 -0
- package/src/web-components/icon-set/logic.js +133 -0
- package/src/web-components/icon-set/static.html +36 -0
- package/src/web-components/icon-set/styles.css +82 -0
- package/src/web-components/icon-specimen/api.json +46 -0
- package/src/web-components/icon-specimen/logic.js +67 -0
- package/src/web-components/icon-specimen/static.html +39 -0
- package/src/web-components/icon-specimen/styles.css +38 -0
- package/src/web-components/layout-specimen/api.json +12 -0
- package/src/web-components/layout-specimen/logic.js +219 -0
- package/src/web-components/layout-specimen/static.html +22 -0
- package/src/web-components/layout-specimen/styles.css +95 -0
- package/src/web-components/motion-specimen/api.json +54 -0
- package/src/web-components/motion-specimen/logic.js +146 -0
- package/src/web-components/motion-specimen/static.html +13 -0
- package/src/web-components/motion-specimen/styles.css +29 -0
- package/src/web-components/palette-generator/_palette-utils.js +133 -0
- package/src/web-components/palette-generator/api.json +79 -0
- package/src/web-components/palette-generator/logic.js +288 -0
- package/src/web-components/palette-generator/static.html +47 -0
- package/src/web-components/palette-generator/styles.css +36 -0
- package/src/web-components/semantic-palette/_color-math.js +149 -0
- package/src/web-components/semantic-palette/api.json +41 -0
- package/src/web-components/semantic-palette/logic.js +400 -0
- package/src/web-components/semantic-palette/static.html +42 -0
- package/src/web-components/semantic-palette/styles.css +20 -0
- package/src/web-components/spacing-specimen/api.json +63 -0
- package/src/web-components/spacing-specimen/logic.js +132 -0
- package/src/web-components/spacing-specimen/static.html +71 -0
- package/src/web-components/spacing-specimen/styles.css +58 -0
- package/src/web-components/theme-catalog/api.json +14 -0
- package/src/web-components/theme-catalog/logic.js +228 -0
- package/src/web-components/theme-catalog/static.html +15 -0
- package/src/web-components/theme-export/api.json +18 -0
- package/src/web-components/theme-export/dtcg-serialize.js +417 -0
- package/src/web-components/theme-export/logic.js +201 -0
- package/src/web-components/theme-export/static.html +12 -0
- package/src/web-components/theme-export/theme-css-parse.js +93 -0
- package/src/web-components/theme-import/api.json +14 -0
- package/src/web-components/theme-import/catalog/atlassian.tokens.json +303 -0
- package/src/web-components/theme-import/catalog/bootstrap.tokens.json +304 -0
- package/src/web-components/theme-import/catalog/carbon.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/catppuccin-mocha.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/govuk.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/manifest.json +132 -0
- package/src/web-components/theme-import/catalog/material.tokens.json +289 -0
- package/src/web-components/theme-import/catalog/salesforce.tokens.json +301 -0
- package/src/web-components/theme-import/catalog/tailwind.tokens.json +302 -0
- package/src/web-components/theme-import/dtcg-deserialize.js +322 -0
- package/src/web-components/theme-import/logic.js +263 -0
- package/src/web-components/theme-import/static.html +15 -0
- package/src/web-components/token-specimen/api.json +24 -0
- package/src/web-components/token-specimen/logic.js +256 -0
- package/src/web-components/token-specimen/static.html +47 -0
- package/src/web-components/token-specimen/styles.css +10 -0
- package/src/web-components/type-specimen/api.json +91 -0
- package/src/web-components/type-specimen/logic.js +156 -0
- package/src/web-components/type-specimen/static.html +84 -0
- package/src/web-components/type-specimen/styles.css +131 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* font-pairer — Interactive font pairing tool
|
|
3
|
+
*
|
|
4
|
+
* Pick up to 4 font roles (heading, body, code, display) from a curated
|
|
5
|
+
* Google Fonts list, preview in multiple contexts, edit sample text live,
|
|
6
|
+
* and export as CSS custom properties.
|
|
7
|
+
*
|
|
8
|
+
* @attr {string} heading-font - Initial heading font family
|
|
9
|
+
* @attr {string} body-font - Initial body font family
|
|
10
|
+
* @attr {string} code-font - Initial code/monospace font family
|
|
11
|
+
* @attr {string} display-font - Initial display/decorative font family
|
|
12
|
+
* @attr {boolean} show-export - Show Copy CSS / Copy CSS @import toolbar
|
|
13
|
+
* @attr {boolean} show-suggestions - Show curated pairing suggestions
|
|
14
|
+
* @attr {string} preview - Preview mode: "combined" (default), "article", "card", "hero"
|
|
15
|
+
*
|
|
16
|
+
* @fires font-pairer:change - { heading, body, code, display }
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <font-pairer heading-font="Playfair Display" body-font="Inter" show-export show-suggestions></font-pairer>
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
23
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
24
|
+
import { copyText } from '../../utils/copy-init.js';
|
|
25
|
+
import { FONTS, SUGGESTED_PAIRINGS, loadFont, googleFontsUrl } from './_font-data.js';
|
|
26
|
+
|
|
27
|
+
/* Editable helper — makes any text contenteditable */
|
|
28
|
+
const E = (font, tag, role, extra = '') =>
|
|
29
|
+
`${extra} contenteditable="plaintext-only" spellcheck="false" class="fp-editable" data-role="${role}"`;
|
|
30
|
+
|
|
31
|
+
const PREVIEWS = {
|
|
32
|
+
combined: {
|
|
33
|
+
label: 'Combined',
|
|
34
|
+
render: (f, t) => `
|
|
35
|
+
<p style="font-family:${f.display};font-size:var(--font-size-3xl,2rem);font-weight:800;margin:0;line-height:1.15"
|
|
36
|
+
${E(f.display,'p','heading')}>${t.heading}</p>
|
|
37
|
+
<h3 style="font-family:${f.heading};font-size:var(--font-size-lg,1.125rem);font-weight:600;margin:0"
|
|
38
|
+
${E(f.heading,'h3','subheading')}>${t.subheading}</h3>
|
|
39
|
+
<p style="font-family:${f.body};font-size:var(--font-size-md,1rem);line-height:1.65;margin:0;color:var(--color-text-muted,#666)"
|
|
40
|
+
${E(f.body,'p','body')}>${t.body}</p>
|
|
41
|
+
<pre style="font-family:${f.code};font-size:var(--font-size-sm,0.875rem);line-height:1.5;margin:0;padding:var(--size-s,0.75rem);background:var(--color-surface-sunken,#eee);border-radius:var(--radius-s,0.25rem);overflow-x:auto"><code ${E(f.code,'code','code')}>${t.code}</code></pre>`,
|
|
42
|
+
},
|
|
43
|
+
article: {
|
|
44
|
+
label: 'Article',
|
|
45
|
+
render: (f, t) => `
|
|
46
|
+
<small style="font-family:${f.body};font-size:var(--font-size-xs,0.75rem);text-transform:uppercase;letter-spacing:0.08em;color:var(--color-interactive,oklch(55% .2 260));font-weight:600"
|
|
47
|
+
${E(f.body,'small','eyebrow')}>${t.eyebrow}</small>
|
|
48
|
+
<h2 style="font-family:${f.heading};font-size:var(--font-size-2xl,1.75rem);font-weight:700;margin:0;line-height:1.2"
|
|
49
|
+
${E(f.heading,'h2','heading')}>${t.heading}</h2>
|
|
50
|
+
<p style="font-family:${f.body};font-size:var(--font-size-sm,0.875rem);color:var(--color-text-muted,#666);margin:0"
|
|
51
|
+
${E(f.body,'p','byline')}>${t.byline}</p>
|
|
52
|
+
<p style="font-family:${f.body};line-height:1.7;margin:0" ${E(f.body,'p','body')}>${t.body}</p>
|
|
53
|
+
<h3 style="font-family:${f.heading};font-size:var(--font-size-lg,1.125rem);font-weight:600;margin:0"
|
|
54
|
+
${E(f.heading,'h3','subheading')}>${t.subheading}</h3>
|
|
55
|
+
<p style="font-family:${f.body};line-height:1.7;margin:0" ${E(f.body,'p','body2')}>${t.body2}</p>
|
|
56
|
+
<blockquote style="font-family:${f.display};font-style:italic;font-size:var(--font-size-lg,1.125rem);border-inline-start:3px solid var(--color-interactive,oklch(55% .2 260));padding-inline-start:var(--size-m,1rem);margin:0;color:var(--color-text-muted,#666)"
|
|
57
|
+
${E(f.display,'blockquote','quote')}>${t.quote}</blockquote>
|
|
58
|
+
<pre style="font-family:${f.code};font-size:var(--font-size-sm,0.875rem);line-height:1.5;margin:0;padding:var(--size-s,0.75rem);background:var(--color-surface-sunken,#eee);border-radius:var(--radius-s,0.25rem);overflow-x:auto"><code ${E(f.code,'code','code')}>${t.code}</code></pre>`,
|
|
59
|
+
},
|
|
60
|
+
'long-article': {
|
|
61
|
+
label: 'Long Article',
|
|
62
|
+
render: (f, t) => `
|
|
63
|
+
<small style="font-family:${f.body};font-size:var(--font-size-xs,0.75rem);text-transform:uppercase;letter-spacing:0.08em;color:var(--color-interactive,oklch(55% .2 260));font-weight:600">${t.eyebrow}</small>
|
|
64
|
+
<h1 style="font-family:${f.display};font-size:var(--font-size-3xl,2rem);font-weight:800;margin:0;line-height:1.15">${t.heading}</h1>
|
|
65
|
+
<p style="font-family:${f.body};font-size:var(--font-size-sm,0.875rem);color:var(--color-text-muted,#666);margin:0">${t.byline}</p>
|
|
66
|
+
<p style="font-family:${f.body};font-size:var(--font-size-lg,1.125rem);line-height:1.6;margin:0;color:var(--color-text-muted,#666)">${t.lead}</p>
|
|
67
|
+
<h2 style="font-family:${f.heading};font-size:var(--font-size-xl,1.375rem);font-weight:700;margin:0">${t.subheading}</h2>
|
|
68
|
+
<p style="font-family:${f.body};line-height:1.7;margin:0">${t.body}</p>
|
|
69
|
+
<p style="font-family:${f.body};line-height:1.7;margin:0">${t.body2}</p>
|
|
70
|
+
<blockquote style="font-family:${f.display};font-style:italic;font-size:var(--font-size-lg,1.125rem);border-inline-start:3px solid var(--color-interactive,oklch(55% .2 260));padding-inline-start:var(--size-m,1rem);margin:0;color:var(--color-text-muted,#666)">${t.quote}</blockquote>
|
|
71
|
+
<h2 style="font-family:${f.heading};font-size:var(--font-size-xl,1.375rem);font-weight:700;margin:0">Implementation Details</h2>
|
|
72
|
+
<p style="font-family:${f.body};line-height:1.7;margin:0">Design tokens form the atomic layer of any visual system. Colors, spacing, typography, and motion are expressed as named variables that cascade through every component. When a token changes, every surface that references it updates automatically — no find-and-replace, no missed instances.</p>
|
|
73
|
+
<pre style="font-family:${f.code};font-size:var(--font-size-sm,0.875rem);line-height:1.5;margin:0;padding:var(--size-s,0.75rem);background:var(--color-surface-sunken,#eee);border-radius:var(--radius-s,0.25rem);overflow-x:auto"><code>${t.code}</code></pre>
|
|
74
|
+
<h3 style="font-family:${f.heading};font-size:var(--font-size-lg,1.125rem);font-weight:600;margin:0">Key Takeaways</h3>
|
|
75
|
+
<ul style="font-family:${f.body};line-height:1.7;margin:0;padding-inline-start:1.5rem">
|
|
76
|
+
<li>Typography sets the emotional tone before a single word is read</li>
|
|
77
|
+
<li>Pair contrast with harmony — serif headings with sans-serif body</li>
|
|
78
|
+
<li>Test at real content lengths, not just sample sentences</li>
|
|
79
|
+
<li>Code fonts need clear distinction between similar glyphs</li>
|
|
80
|
+
</ul>`,
|
|
81
|
+
},
|
|
82
|
+
card: {
|
|
83
|
+
label: 'Card UI',
|
|
84
|
+
render: (f, t) => `
|
|
85
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--size-m,1rem)">
|
|
86
|
+
<div style="border:1px solid var(--color-border,#ddd);border-radius:var(--radius-m,0.5rem);padding:var(--size-m,1rem);display:flex;flex-direction:column;gap:var(--size-xs,0.5rem)">
|
|
87
|
+
<h4 style="font-family:${f.heading};font-weight:600;margin:0" ${E(f.heading,'h4','cardTitle')}>Premium Plan</h4>
|
|
88
|
+
<p style="font-family:${f.display};font-size:var(--font-size-2xl,1.75rem);font-weight:700;margin:0">$49<small style="font-size:0.5em;font-weight:400">/mo</small></p>
|
|
89
|
+
<p style="font-family:${f.body};font-size:var(--font-size-sm,0.875rem);color:var(--color-text-muted,#666);margin:0" ${E(f.body,'p','cardBody')}>Everything you need to build faster.</p>
|
|
90
|
+
<code style="font-family:${f.code};font-size:var(--font-size-xs,0.75rem);background:var(--color-surface-sunken,#eee);padding:0.2rem 0.4rem;border-radius:0.25rem" ${E(f.code,'code','cardCode')}>npm install acme</code>
|
|
91
|
+
</div>
|
|
92
|
+
<div style="border:1px solid var(--color-border,#ddd);border-radius:var(--radius-m,0.5rem);padding:var(--size-m,1rem);display:flex;flex-direction:column;gap:var(--size-xs,0.5rem)">
|
|
93
|
+
<h4 style="font-family:${f.heading};font-weight:600;margin:0">Enterprise</h4>
|
|
94
|
+
<p style="font-family:${f.display};font-size:var(--font-size-2xl,1.75rem);font-weight:700;margin:0">Custom</p>
|
|
95
|
+
<p style="font-family:${f.body};font-size:var(--font-size-sm,0.875rem);color:var(--color-text-muted,#666);margin:0">Dedicated support and SLA guarantees.</p>
|
|
96
|
+
<code style="font-family:${f.code};font-size:var(--font-size-xs,0.75rem);background:var(--color-surface-sunken,#eee);padding:0.2rem 0.4rem;border-radius:0.25rem">contact@acme.dev</code>
|
|
97
|
+
</div>
|
|
98
|
+
</div>`,
|
|
99
|
+
},
|
|
100
|
+
hero: {
|
|
101
|
+
label: 'Hero',
|
|
102
|
+
render: (f, t) => `
|
|
103
|
+
<div style="text-align:center;padding:var(--size-xl,2rem) 0">
|
|
104
|
+
<p style="font-family:${f.body};font-size:var(--font-size-xs,0.75rem);text-transform:uppercase;letter-spacing:0.1em;color:var(--color-interactive,oklch(55% .2 260));font-weight:600;margin:0 0 var(--size-xs,0.5rem)"
|
|
105
|
+
${E(f.body,'p','eyebrow')}>${t.eyebrow}</p>
|
|
106
|
+
<h1 style="font-family:${f.display};font-size:clamp(2rem,5vw,3rem);font-weight:800;margin:0;line-height:1.1"
|
|
107
|
+
${E(f.display,'h1','heading')}>${t.heading}</h1>
|
|
108
|
+
<p style="font-family:${f.body};font-size:var(--font-size-lg,1.125rem);color:var(--color-text-muted,#666);margin:var(--size-s,0.75rem) auto 0;max-width:40ch"
|
|
109
|
+
${E(f.body,'p','body')}>${t.body}</p>
|
|
110
|
+
</div>`,
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
class FontPairer extends VBElement {
|
|
115
|
+
static observedAttributes = ['heading-font', 'body-font', 'code-font', 'display-font', 'sample', 'show-export', 'show-suggestions', 'preview'];
|
|
116
|
+
|
|
117
|
+
#fonts = { heading: '', body: '', code: '', display: '' };
|
|
118
|
+
#preview = 'combined';
|
|
119
|
+
#sampleText = {
|
|
120
|
+
heading: 'The Art of Typography',
|
|
121
|
+
subheading: 'Building visual harmony through font pairing',
|
|
122
|
+
body: 'Good typography is invisible — it lets the content speak without distraction. The best font pairings create harmony between heading impact and body readability. Every typeface carries personality: serif fonts convey trust and tradition, while sans-serif fonts feel modern and clean.',
|
|
123
|
+
body2: 'Choosing the right pairing is about contrast and complement. A bold display font for headlines needs a quiet, readable body font to balance it. Monospace fonts ground technical content in precision. The interplay between these roles defines your brand voice.',
|
|
124
|
+
code: 'const theme = {\n heading: "Playfair Display",\n body: "Inter",\n tokens: { size: 16, scale: 1.25 }\n};',
|
|
125
|
+
eyebrow: 'Design Systems',
|
|
126
|
+
byline: 'By Jamie Chen · April 14, 2026 · 8 min read',
|
|
127
|
+
lead: 'How native CSS features and design tokens are replacing the complex tooling we thought we needed.',
|
|
128
|
+
quote: '"Good typography is invisible — it lets the content breathe."',
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
setup() {
|
|
132
|
+
this.#fonts.heading = this.getAttribute('heading-font') || 'Playfair Display';
|
|
133
|
+
this.#fonts.body = this.getAttribute('body-font') || 'Inter';
|
|
134
|
+
this.#fonts.code = this.getAttribute('code-font') || 'JetBrains Mono';
|
|
135
|
+
this.#fonts.display = this.getAttribute('display-font') || this.#fonts.heading;
|
|
136
|
+
this.#preview = this.getAttribute('preview') || 'combined';
|
|
137
|
+
this.#render();
|
|
138
|
+
this.#loadAllFonts();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
142
|
+
if (oldVal === newVal || !this.isConnected) return;
|
|
143
|
+
if (name === 'heading-font') this.#fonts.heading = newVal || 'Playfair Display';
|
|
144
|
+
if (name === 'body-font') this.#fonts.body = newVal || 'Inter';
|
|
145
|
+
if (name === 'code-font') this.#fonts.code = newVal || 'JetBrains Mono';
|
|
146
|
+
if (name === 'display-font') this.#fonts.display = newVal || this.#fonts.heading;
|
|
147
|
+
if (name === 'preview') this.#preview = newVal || 'combined';
|
|
148
|
+
this.#render();
|
|
149
|
+
this.#loadAllFonts();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async #loadAllFonts() {
|
|
153
|
+
const loads = Object.values(this.#fonts).filter(Boolean).map(name => {
|
|
154
|
+
const f = FONTS.find(x => x.name === name);
|
|
155
|
+
return loadFont(name, f?.weights || '400;700');
|
|
156
|
+
});
|
|
157
|
+
await Promise.all(loads);
|
|
158
|
+
this.#updateFonts();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#render() {
|
|
162
|
+
const showExport = this.hasAttribute('show-export');
|
|
163
|
+
const showSuggestions = this.hasAttribute('show-suggestions');
|
|
164
|
+
|
|
165
|
+
const gap = 'var(--size-m, 1rem)';
|
|
166
|
+
const smGap = 'var(--size-s, 0.75rem)';
|
|
167
|
+
const xsGap = 'var(--size-xs, 0.5rem)';
|
|
168
|
+
const radius = 'var(--radius-m, 0.5rem)';
|
|
169
|
+
const border = 'var(--color-border, #ddd)';
|
|
170
|
+
const surface = 'var(--color-surface, #fff)';
|
|
171
|
+
const raised = 'var(--color-surface-raised, #f5f5f5)';
|
|
172
|
+
const muted = 'var(--color-text-muted, #666)';
|
|
173
|
+
const smFont = 'var(--font-size-sm, 0.875rem)';
|
|
174
|
+
const xsFont = 'var(--font-size-xs, 0.75rem)';
|
|
175
|
+
const selectStyle = `font:inherit;font-size:${smFont};padding:0.35rem 0.5rem;border:1px solid ${border};border-radius:4px;background:${surface}`;
|
|
176
|
+
|
|
177
|
+
// Font role selects — 2×2 grid
|
|
178
|
+
const roles = [
|
|
179
|
+
{ key: 'heading', label: 'Heading' },
|
|
180
|
+
{ key: 'body', label: 'Body' },
|
|
181
|
+
{ key: 'code', label: 'Code' },
|
|
182
|
+
{ key: 'display', label: 'Display' },
|
|
183
|
+
];
|
|
184
|
+
const selects = roles.map(r => `
|
|
185
|
+
<label style="display:flex;flex-direction:column;gap:3px;flex:1;min-width:9rem;font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.04em">
|
|
186
|
+
${r.label}
|
|
187
|
+
<select class="fp-select" data-role="${r.key}" style="${selectStyle}">
|
|
188
|
+
${this.#buildOptions(this.#fonts[r.key])}
|
|
189
|
+
</select>
|
|
190
|
+
</label>
|
|
191
|
+
`).join('');
|
|
192
|
+
|
|
193
|
+
// Preview mode tabs
|
|
194
|
+
const previewTabs = Object.entries(PREVIEWS).map(([key, { label }]) =>
|
|
195
|
+
`<button type="button" class="fp-tab" data-mode="${key}"
|
|
196
|
+
style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.3rem 0.75rem;border-radius:999px;${key === this.#preview ? `background:var(--color-interactive,oklch(55% .2 260));color:white` : `border:1px solid ${border}`}">${label}</button>`
|
|
197
|
+
).join('');
|
|
198
|
+
|
|
199
|
+
// Preview content
|
|
200
|
+
const fontStyles = {
|
|
201
|
+
heading: `'${this.#fonts.heading}', serif`,
|
|
202
|
+
body: `'${this.#fonts.body}', sans-serif`,
|
|
203
|
+
code: `'${this.#fonts.code}', monospace`,
|
|
204
|
+
display: `'${this.#fonts.display}', serif`,
|
|
205
|
+
};
|
|
206
|
+
const previewHTML = PREVIEWS[this.#preview]?.render(fontStyles, this.#sampleText) || '';
|
|
207
|
+
|
|
208
|
+
// Suggestion pills (used in sidebar)
|
|
209
|
+
const suggestionPills = SUGGESTED_PAIRINGS.map(([h, b]) =>
|
|
210
|
+
`<button type="button" class="fp-suggestion" data-h="${h}" data-b="${b}"
|
|
211
|
+
style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.3rem 0.5rem;border:1px solid ${border};border-radius:4px;white-space:nowrap;text-align:left;line-height:1.3"
|
|
212
|
+
title="${h} + ${b}"><strong>${h}</strong><br><span style="color:${muted}">${b}</span></button>`
|
|
213
|
+
).join('');
|
|
214
|
+
|
|
215
|
+
// CSS code output
|
|
216
|
+
const cssCode = this.#buildCSS();
|
|
217
|
+
const codeSection = `<details class="fp-code-details" style="border:1px solid ${border};border-radius:${radius};overflow:hidden"${showExport ? ' open' : ''}>
|
|
218
|
+
<summary style="padding:${xsGap} ${smGap};cursor:pointer;font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.04em;background:${raised}">Code</summary>
|
|
219
|
+
<div style="padding:${smGap}">
|
|
220
|
+
<pre style="font-family:var(--font-mono,monospace);font-size:${xsFont};margin:0;white-space:pre-wrap;word-break:break-all" class="fp-css-output">${cssCode}</pre>
|
|
221
|
+
<div style="display:flex;gap:${xsGap};margin-block-start:${smGap}">
|
|
222
|
+
<button type="button" class="fp-copy-css"
|
|
223
|
+
style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.3rem 0.65rem;border:1px solid ${border};border-radius:4px;background:${surface}">Copy CSS</button>
|
|
224
|
+
<button type="button" class="fp-copy-import"
|
|
225
|
+
style="all:unset;cursor:pointer;font-size:${xsFont};padding:0.3rem 0.65rem;border:1px solid ${border};border-radius:4px;background:${surface}">Copy @import</button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</details>`;
|
|
229
|
+
|
|
230
|
+
// Main layout — content + optional sidebar
|
|
231
|
+
const mainContent = `<div style="display:flex;flex-direction:column;gap:${gap};flex:1;min-width:0">
|
|
232
|
+
<div style="display:flex;flex-wrap:wrap;gap:${xsGap}">${selects}</div>
|
|
233
|
+
<div style="display:flex;flex-wrap:wrap;gap:0.375rem;align-items:center">
|
|
234
|
+
<span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.04em;margin-inline-end:${xsGap}">Examples</span>
|
|
235
|
+
${previewTabs}
|
|
236
|
+
</div>
|
|
237
|
+
<div style="display:flex;flex-direction:column;gap:${xsGap}">
|
|
238
|
+
<span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.04em">Preview</span>
|
|
239
|
+
<div class="fp-preview" style="padding:var(--size-l,1.5rem);background:${raised};border-radius:${radius};border:1px solid ${border};display:flex;flex-direction:column;gap:${smGap};max-height:24rem;overflow-y:auto">
|
|
240
|
+
${previewHTML}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
${codeSection}
|
|
244
|
+
</div>`;
|
|
245
|
+
|
|
246
|
+
if (showSuggestions) {
|
|
247
|
+
this.innerHTML = `<div style="display:flex;gap:${gap};flex-wrap:wrap">
|
|
248
|
+
${mainContent}
|
|
249
|
+
<aside style="flex:0 0 11rem;display:flex;flex-direction:column;gap:${xsGap}">
|
|
250
|
+
<span style="font-size:${xsFont};font-weight:600;color:${muted};text-transform:uppercase;letter-spacing:0.04em">Pairings</span>
|
|
251
|
+
<div style="display:flex;flex-direction:column;gap:0.25rem">${suggestionPills}</div>
|
|
252
|
+
</aside>
|
|
253
|
+
</div>`;
|
|
254
|
+
} else {
|
|
255
|
+
this.innerHTML = mainContent;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
this.#wire();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#buildOptions(selected) {
|
|
262
|
+
const categories = ['serif', 'sans-serif', 'display', 'monospace'];
|
|
263
|
+
return categories.map(cat => {
|
|
264
|
+
const fonts = FONTS.filter(f => f.category === cat);
|
|
265
|
+
const opts = fonts.map(f =>
|
|
266
|
+
`<option value="${f.name}"${f.name === selected ? ' selected' : ''}>${f.name}</option>`
|
|
267
|
+
).join('');
|
|
268
|
+
return `<optgroup label="${cat}">${opts}</optgroup>`;
|
|
269
|
+
}).join('');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#buildCSS() {
|
|
273
|
+
const lines = [];
|
|
274
|
+
const roles = { heading: 'serif', body: 'sans-serif', code: 'monospace', display: 'serif' };
|
|
275
|
+
for (const [role, fallback] of Object.entries(roles)) {
|
|
276
|
+
const name = this.#fonts[role];
|
|
277
|
+
if (!name) continue;
|
|
278
|
+
const f = FONTS.find(x => x.name === name);
|
|
279
|
+
lines.push(`--font-${role}: "${name}", ${f?.category || fallback};`);
|
|
280
|
+
}
|
|
281
|
+
return lines.join('\n');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#buildImports() {
|
|
285
|
+
const seen = new Set();
|
|
286
|
+
const lines = [];
|
|
287
|
+
for (const name of Object.values(this.#fonts)) {
|
|
288
|
+
if (!name || seen.has(name)) continue;
|
|
289
|
+
seen.add(name);
|
|
290
|
+
const f = FONTS.find(x => x.name === name);
|
|
291
|
+
lines.push(`@import url('${googleFontsUrl(name, f?.weights || '400;700')}');`);
|
|
292
|
+
}
|
|
293
|
+
return lines.join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#wire() {
|
|
297
|
+
// Font role selects
|
|
298
|
+
this.querySelectorAll('.fp-select').forEach(sel => {
|
|
299
|
+
sel.addEventListener('change', (e) => {
|
|
300
|
+
const target = /** @type {HTMLSelectElement} */ (e.target);
|
|
301
|
+
this.#fonts[target.dataset.role] = target.value;
|
|
302
|
+
this.#loadAllFonts();
|
|
303
|
+
this.#renderPreview();
|
|
304
|
+
this.#updateCode();
|
|
305
|
+
this.#emit();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Preview mode tabs
|
|
310
|
+
this.querySelectorAll('.fp-tab').forEach(btn => {
|
|
311
|
+
btn.addEventListener('click', () => {
|
|
312
|
+
const el = /** @type {HTMLElement} */ (btn);
|
|
313
|
+
this.#preview = el.dataset.mode || 'combined';
|
|
314
|
+
this.#render();
|
|
315
|
+
this.#loadAllFonts();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Suggestion pills
|
|
320
|
+
this.querySelectorAll('.fp-suggestion').forEach(btn => {
|
|
321
|
+
btn.addEventListener('click', () => {
|
|
322
|
+
const el = /** @type {HTMLElement} */ (btn);
|
|
323
|
+
this.#fonts.heading = el.dataset.h || '';
|
|
324
|
+
this.#fonts.body = el.dataset.b || '';
|
|
325
|
+
this.#fonts.display = el.dataset.h || '';
|
|
326
|
+
this.#render();
|
|
327
|
+
this.#loadAllFonts();
|
|
328
|
+
this.#emit();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Editable text — save changes
|
|
333
|
+
this.querySelectorAll('.fp-editable').forEach(el => {
|
|
334
|
+
const node = /** @type {HTMLElement} */ (el);
|
|
335
|
+
node.addEventListener('blur', () => {
|
|
336
|
+
const role = node.dataset.role;
|
|
337
|
+
if (role && node.textContent.trim()) {
|
|
338
|
+
this.#sampleText[role] = node.textContent.trim();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Copy CSS
|
|
344
|
+
this.querySelector('.fp-copy-css')?.addEventListener('click', (e) => {
|
|
345
|
+
copyText(this.#buildCSS(), {
|
|
346
|
+
button: /** @type {HTMLElement} */ (e.target),
|
|
347
|
+
announceMessage: 'CSS copied',
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Copy CSS @import
|
|
352
|
+
this.querySelector('.fp-copy-import')?.addEventListener('click', (e) => {
|
|
353
|
+
copyText(this.#buildImports(), {
|
|
354
|
+
button: /** @type {HTMLElement} */ (e.target),
|
|
355
|
+
announceMessage: '@import statement copied',
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#renderPreview() {
|
|
361
|
+
const container = this.querySelector('.fp-preview');
|
|
362
|
+
if (!container) return;
|
|
363
|
+
const fontStyles = {
|
|
364
|
+
heading: `'${this.#fonts.heading}', serif`,
|
|
365
|
+
body: `'${this.#fonts.body}', sans-serif`,
|
|
366
|
+
code: `'${this.#fonts.code}', monospace`,
|
|
367
|
+
display: `'${this.#fonts.display}', serif`,
|
|
368
|
+
};
|
|
369
|
+
container.innerHTML = PREVIEWS[this.#preview]?.render(fontStyles, this.#sampleText) || '';
|
|
370
|
+
// Re-wire editable listeners
|
|
371
|
+
container.querySelectorAll('.fp-editable').forEach(el => {
|
|
372
|
+
const node = /** @type {HTMLElement} */ (el);
|
|
373
|
+
node.addEventListener('blur', () => {
|
|
374
|
+
const role = node.dataset.role;
|
|
375
|
+
if (role && node.textContent.trim()) {
|
|
376
|
+
this.#sampleText[role] = node.textContent.trim();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#updateFonts() {
|
|
383
|
+
this.#renderPreview();
|
|
384
|
+
this.#updateCode();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#updateCode() {
|
|
388
|
+
const output = this.querySelector('.fp-css-output');
|
|
389
|
+
if (output) output.textContent = this.#buildCSS();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#emit() {
|
|
393
|
+
this.dispatchEvent(new CustomEvent('font-pairer:change', {
|
|
394
|
+
bubbles: true,
|
|
395
|
+
detail: { ...this.#fonts },
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
registerComponent('font-pairer', FontPairer);
|
|
401
|
+
export { FontPairer };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>Font Pairer (Static Fallback)</title></head>
|
|
4
|
+
<body>
|
|
5
|
+
<noscript>
|
|
6
|
+
<p>font-pairer is an authoring tool for previewing display/body font combinations against live copy. It requires JS to render interactive previews; no static fallback is meaningful.</p>
|
|
7
|
+
</noscript>
|
|
8
|
+
</body>
|
|
9
|
+
</html>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gradient utilities — generate CSS gradient strings from stop data.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{ color: string, position: number }} GradientStop
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a CSS gradient string from stops and options.
|
|
11
|
+
* @param {GradientStop[]} stops
|
|
12
|
+
* @param {{ type?: string, angle?: number, interpolation?: string }} opts
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function buildGradientCSS(stops, { type = 'linear', angle = 90, interpolation = 'oklab' } = {}) {
|
|
16
|
+
if (stops.length < 2) return 'transparent';
|
|
17
|
+
|
|
18
|
+
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
|
19
|
+
const colorStops = sorted.map(s => `${s.color} ${s.position}%`).join(', ');
|
|
20
|
+
const space = interpolation !== 'srgb' ? ` in ${interpolation}` : '';
|
|
21
|
+
|
|
22
|
+
if (type === 'radial') {
|
|
23
|
+
return `radial-gradient(circle${space}, ${colorStops})`;
|
|
24
|
+
}
|
|
25
|
+
return `linear-gradient(${angle}deg${space}, ${colorStops})`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Generate default stops from a comma-separated color string.
|
|
30
|
+
* @param {string} colorsStr
|
|
31
|
+
* @returns {GradientStop[]}
|
|
32
|
+
*/
|
|
33
|
+
export function parseColorStops(colorsStr) {
|
|
34
|
+
if (!colorsStr) return defaultStops();
|
|
35
|
+
|
|
36
|
+
const colors = colorsStr.split(',').map(c => c.trim()).filter(Boolean);
|
|
37
|
+
if (colors.length < 2) return defaultStops();
|
|
38
|
+
|
|
39
|
+
return colors.map((color, i) => ({
|
|
40
|
+
color,
|
|
41
|
+
position: Math.round((i / (colors.length - 1)) * 100),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default two-stop gradient.
|
|
47
|
+
* @returns {GradientStop[]}
|
|
48
|
+
*/
|
|
49
|
+
export function defaultStops() {
|
|
50
|
+
return [
|
|
51
|
+
{ color: '#6366f1', position: 0 },
|
|
52
|
+
{ color: '#ec4899', position: 100 },
|
|
53
|
+
];
|
|
54
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../../schemas/api.schema.json",
|
|
3
|
+
"element": "gradient-builder",
|
|
4
|
+
"type": "web-component",
|
|
5
|
+
"description": "Interactive CSS gradient builder with color stops, angle control, and export",
|
|
6
|
+
"attributes": [
|
|
7
|
+
{
|
|
8
|
+
"name": "colors",
|
|
9
|
+
"kind": "host-api",
|
|
10
|
+
"purpose": "config",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "Comma-separated initial stop colors (e.g. \"#6366f1,#ec4899\")"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "type",
|
|
16
|
+
"kind": "host-api",
|
|
17
|
+
"purpose": "config",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Gradient type: \"linear\" (default) or \"radial\""
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "angle",
|
|
23
|
+
"kind": "host-api",
|
|
24
|
+
"purpose": "config",
|
|
25
|
+
"type": "number",
|
|
26
|
+
"description": "Angle in degrees for linear gradients (default: 90)"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "interpolation",
|
|
30
|
+
"kind": "host-api",
|
|
31
|
+
"purpose": "config",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Color space: \"oklab\" (default), \"oklch\", \"srgb\""
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "show-export",
|
|
37
|
+
"kind": "host-api",
|
|
38
|
+
"purpose": "config",
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "Show Copy CSS toolbar"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "show-controls",
|
|
44
|
+
"kind": "host-api",
|
|
45
|
+
"purpose": "config",
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "Show type/angle/space controls (default: true)"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"events": [
|
|
51
|
+
{
|
|
52
|
+
"name": "gradient-builder:change"
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
"childAttributes": [],
|
|
56
|
+
"structure": [],
|
|
57
|
+
"htmlvalidate": {
|
|
58
|
+
"flow": true,
|
|
59
|
+
"permittedContent": [
|
|
60
|
+
"@flow"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|