@stacklist-app/brandkit 1.3.0 → 1.3.2

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.
@@ -4,6 +4,11 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Changelog</title>
7
+ <!-- FOUC gate (mirrors index.html): hide the page until changelog.js finishes
8
+ its first render so the default theme never flashes. Inline so it applies
9
+ before first paint; changelog.js adds html.bk-ready (with a failsafe against
10
+ a failed config fetch). Reduced-motion users get an instant reveal. -->
11
+ <style>.changelog-page{opacity:0;pointer-events:none}html.bk-ready .changelog-page{opacity:1;pointer-events:auto;transition:opacity 120ms ease-out}@media (prefers-reduced-motion:reduce){html.bk-ready .changelog-page{transition:none}}</style>
7
12
  <link rel="stylesheet" href="styles.css">
8
13
  </head>
9
14
  <body>
package/dist/changelog.js CHANGED
@@ -13,10 +13,24 @@
13
13
  var BASE = (typeof window !== 'undefined' && window.__BRANDKIT_BASE__)
14
14
  ? (String(window.__BRANDKIT_BASE__).replace(/\/+$/, '') || '.') : '.';
15
15
 
16
+ // Reveal the FOUC-gated page. changelog.html hides .changelog-page (opacity:0)
17
+ // until this runs, so the default theme never flashes before the real brand
18
+ // paints. Idempotent. See the matching gate in engine.js.
19
+ function reveal() { document.documentElement.classList.add('bk-ready'); }
20
+ // Failsafe registered BEFORE the fetch: never leave the page permanently blank
21
+ // if config.json is slow or fails — reveal after 1.5s regardless.
22
+ var revealFailsafe = setTimeout(reveal, 1500);
23
+ function done() { clearTimeout(revealFailsafe); reveal(); }
24
+
16
25
  fetch(BASE + '/config.json')
17
- .then(function (res) { return res.json(); })
26
+ // Guard before parsing: a non-200 response (e.g. a CDN/error page served
27
+ // with a JSON body) is routed to the error state instead of init().
28
+ .then(function (res) { if (!res.ok) throw new Error(res.status); return res.json(); })
18
29
  .then(function (config) { init(config); })
19
- .catch(function () { renderError(); });
30
+ .catch(function () { renderError(); })
31
+ // Reveal after render (success) or after the error state is drawn (failure),
32
+ // so the page is always shown rather than stuck blank.
33
+ .then(done, done);
20
34
 
21
35
  /* ================================================================
22
36
  Helpers (kept in sync with dist/engine.js — no module system here)
@@ -34,14 +48,28 @@
34
48
  return stack + ', sans-serif';
35
49
  }
36
50
 
51
+ // Strip the characters a config value would need to break out of the
52
+ // injected <style> block ( < > ), the :root {…} rule ( { } ), or its own
53
+ // declaration to smuggle in another ( ; ). Mirrors cssVal() in
54
+ // dist/engine.js. A real CSS value never contains them.
55
+ function cssVal(v) { return String(v == null ? '' : v).replace(/[<>{};]/g, ''); }
56
+
37
57
  /* ================================================================
38
58
  Bootstrap — fonts + theme variables, mirroring engine.js
39
59
  ================================================================ */
40
60
  function bootstrap(cfg) {
41
61
  if (cfg.fonts) {
62
+ // Dedupe families: when display and body share a font, request it once.
63
+ var seen = {};
42
64
  var families = [];
43
- if (cfg.fonts.display && cfg.fonts.display.googleImport) families.push(cfg.fonts.display.googleImport);
44
- if (cfg.fonts.body && cfg.fonts.body.googleImport) families.push(cfg.fonts.body.googleImport);
65
+ function addFamily(f) {
66
+ if (f && f.googleImport && !seen[f.googleImport]) {
67
+ seen[f.googleImport] = 1;
68
+ families.push(f.googleImport);
69
+ }
70
+ }
71
+ addFamily(cfg.fonts.display);
72
+ addFamily(cfg.fonts.body);
45
73
  if (families.length) {
46
74
  var link = document.createElement('link');
47
75
  link.rel = 'stylesheet';
@@ -54,7 +82,7 @@
54
82
  var vars = [];
55
83
  var keys = Object.keys(cfg.theme);
56
84
  for (var i = 0; i < keys.length; i++) {
57
- vars.push(' ' + keys[i] + ': ' + cfg.theme[keys[i]] + ';');
85
+ vars.push(' ' + cssVal(keys[i]) + ': ' + cssVal(cfg.theme[keys[i]]) + ';');
58
86
  }
59
87
  if (cfg.fonts) {
60
88
  if (cfg.fonts.display) vars.push(' --font-display: ' + fontStack(cfg.fonts.display) + ';');
package/dist/engine.js CHANGED
@@ -12,9 +12,31 @@
12
12
  // Default '.' keeps the original page-relative behavior (dev + root serving).
13
13
  var BASE = (typeof window !== 'undefined' && window.__BRANDKIT_BASE__)
14
14
  ? (String(window.__BRANDKIT_BASE__).replace(/\/+$/, '') || '.') : '.';
15
- var res = await fetch(BASE + '/config.json');
16
- var config = await res.json();
17
- init(config);
15
+
16
+ // Reveal the FOUC-gated shell. index.html hides .layout (opacity:0) until this
17
+ // runs, so the default theme never flashes before the real brand paints. The
18
+ // class is added once the first render pass below completes. Idempotent.
19
+ function reveal() { document.documentElement.classList.add('bk-ready'); }
20
+ // Failsafe registered BEFORE the fetch: if config.json is slow or fails, never
21
+ // leave the page permanently blank — reveal after 1.5s no matter what. The
22
+ // finally below clears this on the normal path so it doesn't fire twice.
23
+ var revealFailsafe = setTimeout(reveal, 1500);
24
+
25
+ try {
26
+ var res = await fetch(BASE + '/config.json');
27
+ // Guard before parsing: a non-200 response (e.g. a CDN/error page served
28
+ // with a JSON body) would otherwise flow straight into init() and render
29
+ // confusing output instead of failing cleanly.
30
+ if (!res.ok) throw new Error('Failed to load config.json: ' + res.status);
31
+ var config = await res.json();
32
+ init(config);
33
+ } finally {
34
+ // Reveal after init()'s synchronous render pass (or on error, so a failed
35
+ // load shows the page rather than a blank frame). The throw still surfaces
36
+ // as an unhandled rejection for debugging — finally doesn't swallow it.
37
+ clearTimeout(revealFailsafe);
38
+ reveal();
39
+ }
18
40
 
19
41
  function init(cfg) {
20
42
  var copyFormat = localStorage.getItem('brandkit-copy-format') || 'hex';
@@ -44,15 +66,36 @@
44
66
  return stack + ', sans-serif';
45
67
  }
46
68
 
69
+ /* ==============================================================
70
+ CSS-value sanitizer — strips the characters a config value would
71
+ need to break out of the injected <style> block ( < > ), out of the
72
+ :root {…} rule ( { } ), or out of its own declaration to smuggle in
73
+ another one ( ; ). A legitimate custom-property token/value never
74
+ contains them, so real configs pass through unchanged; an
75
+ adversarial value like "}</style><script>..." or
76
+ "red; background-image:url(x)" is defanged.
77
+ ============================================================== */
78
+ function cssVal(v) { return String(v == null ? '' : v).replace(/[<>{};]/g, ''); }
79
+
47
80
  /* ==============================================================
48
81
  BOOTSTRAP — inject fonts + CSS variables before any rendering
49
82
  ============================================================== */
50
83
  function bootstrap() {
51
84
  // Inject Google Fonts
52
85
  if (cfg.fonts) {
86
+ // Dedupe families: when display and body share a font (e.g. both
87
+ // Inter), requesting it once avoids a redundant URL + duplicate
88
+ // font CSS work.
89
+ var seen = {};
53
90
  var families = [];
54
- if (cfg.fonts.display && cfg.fonts.display.googleImport) families.push(cfg.fonts.display.googleImport);
55
- if (cfg.fonts.body && cfg.fonts.body.googleImport) families.push(cfg.fonts.body.googleImport);
91
+ function addFamily(f) {
92
+ if (f && f.googleImport && !seen[f.googleImport]) {
93
+ seen[f.googleImport] = 1;
94
+ families.push(f.googleImport);
95
+ }
96
+ }
97
+ addFamily(cfg.fonts.display);
98
+ addFamily(cfg.fonts.body);
56
99
  if (families.length) {
57
100
  var link = document.createElement('link');
58
101
  link.rel = 'stylesheet';
@@ -66,7 +109,7 @@
66
109
  var vars = [];
67
110
  var keys = Object.keys(cfg.theme);
68
111
  for (var i = 0; i < keys.length; i++) {
69
- vars.push(' ' + keys[i] + ': ' + cfg.theme[keys[i]] + ';');
112
+ vars.push(' ' + cssVal(keys[i]) + ': ' + cssVal(cfg.theme[keys[i]]) + ';');
70
113
  }
71
114
  // Add font variables from config
72
115
  if (cfg.fonts) {
@@ -202,7 +245,7 @@
202
245
  '<circle cx="12" cy="12" r="9"/>' +
203
246
  '</svg>' +
204
247
  '<span class="sidebar-changelog-label">Changelog</span>' +
205
- '<span class="sidebar-changelog-version">v' + esc(cfg.brand && cfg.brand.version) + '</span>';
248
+ '<span class="sidebar-changelog-version">v' + esc((cfg.brand && cfg.brand.version) || '') + '</span>';
206
249
  link.hidden = false;
207
250
  }
208
251
 
package/dist/index.html CHANGED
@@ -4,6 +4,13 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Brand Guide</title>
7
+ <!-- FOUC gate: hide the shell until engine.js finishes its first render, so the
8
+ default theme never flashes before the real brand paints. Inline so it
9
+ applies before first paint (an external sheet would itself flash). engine.js
10
+ adds html.bk-ready once rendered (with a failsafe so a failed config fetch
11
+ can't leave the page permanently blank). Reduced-motion users get an instant
12
+ reveal (gate kept, fade dropped). -->
13
+ <style>.layout{opacity:0;pointer-events:none}html.bk-ready .layout{opacity:1;pointer-events:auto;transition:opacity 120ms ease-out}@media (prefers-reduced-motion:reduce){html.bk-ready .layout{transition:none}}</style>
7
14
  <link rel="stylesheet" href="styles.css">
8
15
  </head>
9
16
  <body>
@@ -12,7 +19,9 @@
12
19
 
13
20
  <!-- Sidebar -->
14
21
  <nav class="sidebar">
15
- <div class="sidebar-brand">brandkit</div>
22
+ <!-- Filled from config.brand (name/sidebarLogo) by renderShell(); left empty
23
+ so the pre-render frame carries no default brand identity. -->
24
+ <div class="sidebar-brand"></div>
16
25
  <ul class="sidebar-nav" id="nav"></ul>
17
26
  <div class="sidebar-agent" id="agent-callout" hidden></div>
18
27
  <a class="sidebar-changelog" id="sidebar-changelog" href="changelog.html" hidden></a>
package/lib/template.js CHANGED
@@ -19,6 +19,20 @@ function fontStack(font) {
19
19
  return stack + ', sans-serif';
20
20
  }
21
21
 
22
+ /**
23
+ * Strip the characters a config value would need to break out of the
24
+ * generated :root block ( < > ), out of the :root {…} rule ( { } ), or out
25
+ * of its own declaration to smuggle in another one ( ; ). A legitimate CSS
26
+ * token/value never contains them, so real configs pass through unchanged;
27
+ * an adversarial value like "}</style><script>..." or
28
+ * "red; background-image:url(x)" is defanged. Mirrors cssVal() in
29
+ * dist/engine.js + dist/changelog.js so dev (runtime-injected) and
30
+ * production (build-baked) :root blocks sanitize identically.
31
+ */
32
+ function cssValue(v) {
33
+ return String(v == null ? '' : v).replace(/[<>{};]/g, '');
34
+ }
35
+
22
36
  /**
23
37
  * Generate a :root CSS block from config.theme + config.fonts.
24
38
  */
@@ -27,7 +41,7 @@ function generateRootCSS(config) {
27
41
  if (config.theme) {
28
42
  var keys = Object.keys(config.theme);
29
43
  for (var i = 0; i < keys.length; i++) {
30
- lines.push(' ' + keys[i] + ': ' + config.theme[keys[i]] + ';');
44
+ lines.push(' ' + cssValue(keys[i]) + ': ' + cssValue(config.theme[keys[i]]) + ';');
31
45
  }
32
46
  }
33
47
  if (config.fonts) {
@@ -73,13 +87,19 @@ function normalizeBasePath(bp) {
73
87
  */
74
88
  function generateFontsLink(config) {
75
89
  if (!config.fonts) return '';
90
+ // Dedupe families: when display and body share a font (e.g. both Inter),
91
+ // baking it once avoids a redundant ...&family=Inter:...&family=Inter:...
92
+ // URL and the duplicate font CSS work it triggers.
93
+ var seen = {};
76
94
  var families = [];
77
- if (config.fonts.display && config.fonts.display.googleImport) {
78
- families.push(config.fonts.display.googleImport);
79
- }
80
- if (config.fonts.body && config.fonts.body.googleImport) {
81
- families.push(config.fonts.body.googleImport);
95
+ function addFamily(f) {
96
+ if (f && f.googleImport && !seen[f.googleImport]) {
97
+ seen[f.googleImport] = 1;
98
+ families.push(f.googleImport);
99
+ }
82
100
  }
101
+ addFamily(config.fonts.display);
102
+ addFamily(config.fonts.body);
83
103
  if (!families.length) return '';
84
104
  return '<link href="https://fonts.googleapis.com/css2?family=' +
85
105
  families.join('&family=') + '&display=swap" rel="stylesheet">';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stacklist-app/brandkit",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Config-driven brand guide that bolts onto any website. Zero dependencies.",
5
5
  "main": "lib/resolve.js",
6
6
  "bin": {