@filipgutica/ui 1.3.0 → 1.4.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/README.md CHANGED
@@ -85,7 +85,55 @@ applySemanticTheme({ root: document.documentElement, theme })
85
85
 
86
86
  Themes supply colors; the library keeps typography, spacing, and component
87
87
  structure consistent. Open VSX search and imports are available through
88
- `@filipgutica/ui/open-vsx`, which must run on a Node server.
88
+ `createOpenVsxThemeService` from `@filipgutica/ui/open-vsx`. It runs on Node
89
+ or in a browser with HTTPS and Web Crypto. Browser requests depend on the
90
+ registry and its resource host allowing cross-origin access.
91
+
92
+ The gallery's search-and-install dialog is docs-only, not an exported component.
93
+ Applications can build their own picker with the exported components and theme
94
+ helpers. `UiSiteHeader` includes the built-in System, Light, and Dark control.
95
+
96
+ The default theme is unchanged. To opt in to Graphite Indigo, import it after
97
+ `theme.css`:
98
+
99
+ ```css
100
+ @import "@filipgutica/ui/theme.css";
101
+ @import "@filipgutica/ui/themes/graphite-indigo.css";
102
+ ```
103
+
104
+ ## Project sites
105
+
106
+ The `site` entry gives the project sites one header, one appearance choice, and
107
+ shared page styles. Import `@filipgutica/ui/site.css` and the Graphite Indigo
108
+ theme above, which supplies the type and spacing roles `site.css` reads. Keep
109
+ document-wide link styles in `@layer base` so the header owns its navigation
110
+ appearance.
111
+
112
+ ```vue
113
+ <script setup lang="ts">
114
+ import { UiSiteHeader } from '@filipgutica/ui/site'
115
+ </script>
116
+
117
+ <template>
118
+ <UiSiteHeader
119
+ project="wtree"
120
+ :links="[{ label: 'User guide', href: '#guide' }]"
121
+ />
122
+ </template>
123
+ ```
124
+
125
+ `project` is `wtree`, `annoterm`, `devps`, `workbench`, or `ui`. The project
126
+ menu is a native disclosure that works without JavaScript; Escape closes it and
127
+ returns focus to its button. Without a default slot the header renders a
128
+ System, Light, and Dark picker. A page with its own appearance control passes it
129
+ in the default slot and uses `useSiteAppearance({ applyToRoot: false })` when it
130
+ owns the root element.
131
+
132
+ `useSiteAppearance()` returns `choice`, the resolved `appearance`, and
133
+ `chooseAppearance(value)`. The choice is stored under `tool-site-theme`, follows
134
+ the operating system and other tabs, and is safe during server rendering and
135
+ when storage is blocked. To avoid a flash of the wrong appearance, inline
136
+ `siteThemeBootScript` in the document head.
89
137
 
90
138
  ## Development
91
139
 
@@ -94,3 +142,8 @@ pnpm install
94
142
  pnpm docs:dev
95
143
  pnpm verify
96
144
  ```
145
+
146
+ Graphite Indigo values live in `themes/graphite-indigo.json`. After editing it,
147
+ run `pnpm themes:build` to regenerate the opt-in theme and the standalone
148
+ `themes/graphite-indigo.tokens.css`; `pnpm themes:check` fails when either is
149
+ stale.
package/dist/open-vsx.js CHANGED
@@ -1,38 +1,37 @@
1
1
  import { i as e, t } from "./theme-CIwueiir.js";
2
2
  import { parse as n } from "jsonc-parser";
3
- import { createHash as r } from "node:crypto";
4
- import i from "jszip";
3
+ import r from "jszip";
5
4
  //#region src/open-vsx/index.ts
6
- var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `${a}/api/-/search`, c = 524288, l = 262144, u = 262144, d = 1048576, f = 20971520, p = 5e3, m = 104857600, h = 200, g = 40, _ = 8, v = 15e3, y = class extends Error {
5
+ var i = "https://open-vsx.org", a = "https://openvsx.eclipsecontent.org", o = `${i}/api/-/search`, s = 524288, c = 262144, l = 262144, u = 1048576, d = 20971520, f = 5e3, p = 104857600, m = 200, h = 40, g = 8, _ = 15e3, v = class extends Error {
7
6
  code;
8
7
  constructor(e, t) {
9
8
  super(t), this.name = "OpenVsxThemeError", this.code = e;
10
9
  }
11
- }, b = (e) => typeof e == "object" && !!e && !Array.isArray(e), x = (e, t) => {
10
+ }, y = (e) => typeof e == "object" && !!e && !Array.isArray(e), b = (e, t) => {
12
11
  let r = [], i = n(e, r, { allowTrailingComma: !0 });
13
- if (r.length > 0 || !b(i)) throw new y("unsupported-extension", `${t} is not valid JSON.`);
12
+ if (r.length > 0 || !y(i)) throw new v("unsupported-extension", `${t} is not valid JSON.`);
14
13
  return i;
15
- }, S = (e, { allowResourceOrigin: t = !1 } = {}) => {
14
+ }, x = (e, { allowResourceOrigin: t = !1 } = {}) => {
16
15
  if (typeof e != "string") return null;
17
16
  try {
18
17
  let n = new URL(e), r = n.origin.toLowerCase();
19
- return r === a || t && r === o ? n.toString() : null;
18
+ return r === i || t && r === a ? n.toString() : null;
20
19
  } catch {
21
20
  return null;
22
21
  }
23
- }, C = (e) => {
22
+ }, S = (e) => {
24
23
  let t = /^([a-z0-9][a-z0-9_-]{0,99})\.([a-z0-9][a-z0-9_-]{0,99})$/i.exec(e);
25
- if (!t?.[1] || !t[2]) throw new y("unsupported-extension", "The selected Open VSX extension identifier is invalid.");
24
+ if (!t?.[1] || !t[2]) throw new v("unsupported-extension", "The selected Open VSX extension identifier is invalid.");
26
25
  return [t[1], t[2]];
27
- }, w = (e) => {
28
- let t = b(e.contributes) ? e.contributes : null;
29
- return Array.isArray(t?.themes) ? t.themes.filter(b) : [];
30
- }, T = async (e, t, n) => {
26
+ }, C = (e) => {
27
+ let t = y(e.contributes) ? e.contributes : null;
28
+ return Array.isArray(t?.themes) ? t.themes.filter(y) : [];
29
+ }, w = async (e, t, n) => {
31
30
  let r = Number(e.headers.get("content-length"));
32
- if (Number.isFinite(r) && r > t) throw new y("file-too-large", n);
31
+ if (Number.isFinite(r) && r > t) throw new v("file-too-large", n);
33
32
  if (!e.body) {
34
33
  let r = new Uint8Array(await e.arrayBuffer());
35
- if (r.byteLength > t) throw new y("file-too-large", n);
34
+ if (r.byteLength > t) throw new v("file-too-large", n);
36
35
  return r;
37
36
  }
38
37
  let i = e.body.getReader(), a = [], o = 0;
@@ -40,7 +39,7 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
40
39
  for (;;) {
41
40
  let { done: e, value: r } = await i.read();
42
41
  if (e) break;
43
- if (o += r.byteLength, o > t) throw await i.cancel(), new y("file-too-large", n);
42
+ if (o += r.byteLength, o > t) throw await i.cancel(), new v("file-too-large", n);
44
43
  a.push(r);
45
44
  }
46
45
  } finally {
@@ -49,24 +48,24 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
49
48
  let s = new Uint8Array(o), c = 0;
50
49
  for (let e of a) s.set(e, c), c += e.byteLength;
51
50
  return s;
52
- }, E = (e) => async ({ url: t, limit: n, failureMessage: r, tooLargeMessage: i, allowResourceOrigin: a = !1 }) => {
53
- let o = new AbortController(), s = setTimeout(() => o.abort(), v);
51
+ }, T = (e) => async ({ url: t, limit: n, failureMessage: r, tooLargeMessage: i, allowResourceOrigin: a = !1 }) => {
52
+ let o = new AbortController(), s = setTimeout(() => o.abort(), _);
54
53
  try {
55
- let s = S(String(t), { allowResourceOrigin: a });
56
- if (!s) throw new y("open-vsx-unavailable", r);
54
+ let s = x(String(t), { allowResourceOrigin: a });
55
+ if (!s) throw new v("open-vsx-unavailable", r);
57
56
  let c = await e(s, {
58
57
  redirect: "follow",
59
58
  signal: o.signal
60
59
  });
61
- if (!S(c.url || s, { allowResourceOrigin: a }) || !c.ok) throw new y("open-vsx-unavailable", r);
62
- return await T(c, n, i);
60
+ if (!x(c.url || s, { allowResourceOrigin: a }) || !c.ok) throw new v("open-vsx-unavailable", r);
61
+ return await w(c, n, i);
63
62
  } catch (e) {
64
- throw e instanceof y ? e : new y("open-vsx-unavailable", o.signal.aborted ? "Open VSX took too long to respond." : r);
63
+ throw e instanceof v ? e : new v("open-vsx-unavailable", o.signal.aborted ? "Open VSX took too long to respond." : r);
65
64
  } finally {
66
65
  clearTimeout(s);
67
66
  }
68
- }, D = (e) => {
69
- if (!b(e)) return null;
67
+ }, E = (e) => {
68
+ if (!y(e)) return null;
70
69
  let t = typeof e.namespace == "string" ? e.namespace.trim() : "", n = typeof e.name == "string" ? e.name.trim() : "";
71
70
  if (!t || !n) return null;
72
71
  let r = typeof e.displayName == "string" && e.displayName.trim() ? e.displayName.trim() : n;
@@ -77,49 +76,49 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
77
76
  description: typeof e.description == "string" ? e.description.slice(0, 300) : "",
78
77
  downloadCount: typeof e.downloadCount == "number" && Number.isFinite(e.downloadCount) ? Math.max(0, e.downloadCount) : 0
79
78
  };
80
- }, O = (e) => {
81
- if (e.length > 1024 || e.includes("\0") || e.startsWith("/") || /^[a-z]:/i.test(e)) throw new y("unsupported-extension", "A theme path is not safe.");
82
- }, k = ({ path: e, initialSegments: t, minimumDepth: n }) => {
83
- O(e);
79
+ }, D = (e) => {
80
+ if (e.length > 1024 || e.includes("\0") || e.startsWith("/") || /^[a-z]:/i.test(e)) throw new v("unsupported-extension", "A theme path is not safe.");
81
+ }, O = ({ path: e, initialSegments: t, minimumDepth: n }) => {
82
+ D(e);
84
83
  let r = [...t];
85
84
  for (let t of e.replaceAll("\\", "/").split("/")) if (!(!t || t === ".")) {
86
85
  if (t === "..") {
87
- if (r.length <= n) throw new y("unsupported-extension", "A theme path escapes its package.");
86
+ if (r.length <= n) throw new v("unsupported-extension", "A theme path escapes its package.");
88
87
  r.pop();
89
88
  } else r.push(t);
90
89
  }
91
90
  return r.join("/");
92
- }, A = (e, t = "extension/") => {
91
+ }, k = (e, t = "extension/") => {
93
92
  let n = t.split("/").slice(0, -1);
94
- return n[0] !== "extension" && n.unshift("extension"), k({
93
+ return n[0] !== "extension" && n.unshift("extension"), O({
95
94
  path: e,
96
95
  initialSegments: n,
97
96
  minimumDepth: 1
98
97
  });
99
- }, j = (e) => {
100
- let t = k({
98
+ }, A = (e) => {
99
+ let t = O({
101
100
  path: e,
102
101
  initialSegments: [],
103
102
  minimumDepth: 0
104
103
  });
105
- if (!t) throw new y("unsupported-extension", "The extension package has an invalid file name.");
104
+ if (!t) throw new v("unsupported-extension", "The extension package has an invalid file name.");
106
105
  return t;
107
- }, M = (e) => {
106
+ }, j = (e) => {
108
107
  let t = e.byteLength, n = Math.max(0, t - 65557), r = t - 22;
109
108
  for (; r >= n && (e.getUint32(r, !0) !== 101010256 || r + 22 + e.getUint16(r + 20, !0) !== t);) --r;
110
- if (r < n) throw new y("unsupported-extension", "The extension package is not a valid ZIP file.");
109
+ if (r < n) throw new v("unsupported-extension", "The extension package is not a valid ZIP file.");
111
110
  return r;
112
- }, N = (e) => {
113
- let t = M(e), n = e.getUint32(t + 12, !0), r = e.getUint32(t + 16, !0), i = r + n;
114
- if (i !== t || i > e.byteLength) throw new y("unsupported-extension", "The extension package has an invalid ZIP directory.");
111
+ }, M = (e) => {
112
+ let t = j(e), n = e.getUint32(t + 12, !0), r = e.getUint32(t + 16, !0), i = r + n;
113
+ if (i !== t || i > e.byteLength) throw new v("unsupported-extension", "The extension package has an invalid ZIP directory.");
115
114
  return {
116
115
  start: r,
117
116
  end: i
118
117
  };
119
- }, P = ({ bytes: e, view: t, offset: n, directoryEnd: r }) => {
120
- if (n + 46 > r || t.getUint32(n, !0) !== 33639248) throw new y("unsupported-extension", "The extension package has an invalid ZIP directory.");
118
+ }, N = ({ bytes: e, view: t, offset: n, directoryEnd: r }) => {
119
+ if (n + 46 > r || t.getUint32(n, !0) !== 33639248) throw new v("unsupported-extension", "The extension package has an invalid ZIP directory.");
121
120
  let i = t.getUint32(n + 20, !0), a = t.getUint32(n + 24, !0), o = t.getUint16(n + 28, !0), s = t.getUint16(n + 30, !0), c = t.getUint16(n + 32, !0), l = n + 46 + o + s + c;
122
- if (l > r || i === 4294967295 || a === 4294967295 || a > 0 && (i === 0 || a / i > h)) throw new y("unsupported-extension", "The extension package has unsafe ZIP metadata.");
121
+ if (l > r || i === 4294967295 || a === 4294967295 || a > 0 && (i === 0 || a / i > m)) throw new v("unsupported-extension", "The extension package has unsafe ZIP metadata.");
123
122
  try {
124
123
  return {
125
124
  name: new TextDecoder("utf-8", { fatal: !0 }).decode(e.subarray(n + 46, n + 46 + o)),
@@ -127,32 +126,32 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
127
126
  nextOffset: l
128
127
  };
129
128
  } catch {
130
- throw new y("unsupported-extension", "The extension package has an invalid file name.");
129
+ throw new v("unsupported-extension", "The extension package has an invalid file name.");
131
130
  }
132
- }, F = (e) => {
133
- let t = new DataView(e.buffer, e.byteOffset, e.byteLength), n = N(t), r = /* @__PURE__ */ new Map(), i = 0, a = 0, o = n.start;
131
+ }, P = (e) => {
132
+ let t = new DataView(e.buffer, e.byteOffset, e.byteLength), n = M(t), r = /* @__PURE__ */ new Map(), i = 0, a = 0, o = n.start;
134
133
  for (; o < n.end;) {
135
- if (a += 1, a > p) throw new y("unsupported-extension", "The extension package has an invalid ZIP directory.");
136
- let s = P({
134
+ if (a += 1, a > f) throw new v("unsupported-extension", "The extension package has an invalid ZIP directory.");
135
+ let s = N({
137
136
  bytes: e,
138
137
  view: t,
139
138
  offset: o,
140
139
  directoryEnd: n.end
141
140
  });
142
- if (i += s.uncompressed, i > m) throw new y("file-too-large", "The extension package expands beyond the safe limit.");
143
- r.set(j(s.name), s.uncompressed), o = s.nextOffset;
141
+ if (i += s.uncompressed, i > p) throw new v("file-too-large", "The extension package expands beyond the safe limit.");
142
+ r.set(A(s.name), s.uncompressed), o = s.nextOffset;
144
143
  }
145
144
  return r;
146
- }, I = async ({ zip: e, sizes: t, path: n, description: r }) => {
145
+ }, F = async ({ zip: e, sizes: t, path: n, description: r }) => {
147
146
  let i = t.get(n);
148
- if (i === void 0) throw new y("unsupported-extension", `${r} is missing.`);
149
- if (i > d) throw new y("file-too-large", `${r} is too large.`);
147
+ if (i === void 0) throw new v("unsupported-extension", `${r} is missing.`);
148
+ if (i > u) throw new v("file-too-large", `${r} is too large.`);
150
149
  let a = e.file(n);
151
- if (!a) throw new y("unsupported-extension", `${r} is missing.`);
150
+ if (!a) throw new v("unsupported-extension", `${r} is missing.`);
152
151
  return a.async("string");
153
- }, L = async ({ zip: e, sizes: t, path: n, ancestors: r = /* @__PURE__ */ new Set() }) => {
154
- if (r.size >= _ || r.has(n)) throw new y("unsupported-extension", "Theme includes are cyclic or too deeply nested.");
155
- let i = x(await I({
152
+ }, I = async ({ zip: e, sizes: t, path: n, ancestors: r = /* @__PURE__ */ new Set() }) => {
153
+ if (r.size >= g || r.has(n)) throw new v("unsupported-extension", "Theme includes are cyclic or too deeply nested.");
154
+ let i = b(await F({
156
155
  zip: e,
157
156
  sizes: t,
158
157
  path: n,
@@ -161,50 +160,50 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
161
160
  if (typeof i.include != "string") return i;
162
161
  let a = new Set(r);
163
162
  a.add(n);
164
- let o = await L({
163
+ let o = await I({
165
164
  zip: e,
166
165
  sizes: t,
167
- path: A(i.include, n),
166
+ path: k(i.include, n),
168
167
  ancestors: a
169
168
  });
170
169
  return {
171
170
  ...o,
172
171
  ...i,
173
172
  colors: {
174
- ...b(o.colors) ? o.colors : {},
175
- ...b(i.colors) ? i.colors : {}
173
+ ...y(o.colors) ? o.colors : {},
174
+ ...y(i.colors) ? i.colors : {}
176
175
  }
177
176
  };
178
- }, R = (e) => e === "vs" ? "light" : e === "vs-dark" ? "dark" : e === "hc-black" ? "hc" : e === "hc-light" ? "hcLight" : null, z = (e, t) => e.find((e) => t === "dark" ? e.appearance === "dark" || e.appearance === "high-contrast-dark" : e.appearance === "light" || e.appearance === "high-contrast-light") ?? e[0], B = async ({ request: e, extensionId: t }) => {
179
- let [n, r] = C(t), i = await e({
180
- url: `${a}/api/${encodeURIComponent(n)}/${encodeURIComponent(r)}`,
181
- limit: l,
177
+ }, L = (e) => e === "vs" ? "light" : e === "vs-dark" ? "dark" : e === "hc-black" ? "hc" : e === "hc-light" ? "hcLight" : null, R = (e, t) => e.find((e) => t === "dark" ? e.appearance === "dark" || e.appearance === "high-contrast-dark" : e.appearance === "light" || e.appearance === "high-contrast-light") ?? e[0], z = async ({ request: e, extensionId: t }) => {
178
+ let [n, r] = S(t), a = await e({
179
+ url: `${i}/api/${encodeURIComponent(n)}/${encodeURIComponent(r)}`,
180
+ limit: c,
182
181
  failureMessage: "Open VSX theme details are unavailable right now.",
183
182
  tooLargeMessage: "Open VSX returned unexpectedly large theme details."
184
- }), o = JSON.parse(new TextDecoder().decode(i));
185
- if (!b(o) || !b(o.files)) throw new y("unsupported-extension", "Open VSX returned malformed theme details.");
186
- let s = { allowResourceOrigin: !0 }, c = S(o.files.manifest, s), u = S(o.files.download, s), d = S(o.files.sha256, s), f = typeof o.version == "string" ? o.version : "";
187
- if (!c || !u || !d || !f) throw new y("unsupported-extension", "Open VSX returned malformed theme details.");
183
+ }), o = JSON.parse(new TextDecoder().decode(a));
184
+ if (!y(o) || !y(o.files)) throw new v("unsupported-extension", "Open VSX returned malformed theme details.");
185
+ let s = { allowResourceOrigin: !0 }, l = x(o.files.manifest, s), u = x(o.files.download, s), d = x(o.files.sha256, s), f = typeof o.version == "string" ? o.version : "";
186
+ if (!l || !u || !d || !f) throw new v("unsupported-extension", "Open VSX returned malformed theme details.");
188
187
  return {
189
188
  extensionName: r,
190
- manifestUrl: c,
189
+ manifestUrl: l,
191
190
  packageUrl: u,
192
191
  checksumUrl: d,
193
192
  version: f
194
193
  };
195
- }, V = async ({ request: e, manifestUrl: t }) => {
194
+ }, B = async ({ request: e, manifestUrl: t }) => {
196
195
  let n = await e({
197
196
  url: t,
198
- limit: u,
197
+ limit: l,
199
198
  failureMessage: "That extension has no readable manifest.",
200
199
  tooLargeMessage: "That extension manifest is too large.",
201
200
  allowResourceOrigin: !0
202
- }), r = w(x(new TextDecoder().decode(n), "The extension manifest"));
203
- if (r.length === 0 || r.length > g) throw new y("unsupported-extension", "That extension does not contain a supported number of color themes.");
204
- }, H = async ({ request: e, packageUrl: t, checksumUrl: n }) => {
205
- let [i, a] = await Promise.all([e({
201
+ }), r = C(b(new TextDecoder().decode(n), "The extension manifest"));
202
+ if (r.length === 0 || r.length > h) throw new v("unsupported-extension", "That extension does not contain a supported number of color themes.");
203
+ }, V = async ({ request: e, packageUrl: t, checksumUrl: n }) => {
204
+ let [r, i] = await Promise.all([e({
206
205
  url: t,
207
- limit: f,
206
+ limit: d,
208
207
  failureMessage: "That Open VSX theme could not be downloaded.",
209
208
  tooLargeMessage: "That theme extension is too large to import safely.",
210
209
  allowResourceOrigin: !0
@@ -214,40 +213,40 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
214
213
  failureMessage: "That Open VSX theme has no readable checksum.",
215
214
  tooLargeMessage: "That Open VSX checksum response is invalid.",
216
215
  allowResourceOrigin: !0
217
- })]), o = new TextDecoder().decode(a).trim().split(/\s+/)[0], s = r("sha256").update(i).digest("hex");
218
- if (!o || !/^[a-f\d]{64}$/i.test(o)) throw new y("integrity-failed", "That Open VSX theme has an invalid checksum.");
219
- if (o.toLowerCase() !== s) throw new y("integrity-failed", "That Open VSX theme failed its integrity check.");
220
- return i;
221
- }, U = async (e) => {
222
- let t = F(e);
216
+ })]), a = new TextDecoder().decode(i).trim().split(/\s+/)[0], o = await globalThis.crypto.subtle.digest("SHA-256", new Uint8Array(r)), s = Array.from(new Uint8Array(o), (e) => e.toString(16).padStart(2, "0")).join("");
217
+ if (!a || !/^[a-f\d]{64}$/i.test(a)) throw new v("integrity-failed", "That Open VSX theme has an invalid checksum.");
218
+ if (a.toLowerCase() !== s) throw new v("integrity-failed", "That Open VSX theme failed its integrity check.");
219
+ return r;
220
+ }, H = async (e) => {
221
+ let t = P(e);
223
222
  try {
224
223
  return {
225
- zip: await i.loadAsync(e),
224
+ zip: await r.loadAsync(e),
226
225
  sizes: t
227
226
  };
228
227
  } catch {
229
- throw new y("unsupported-extension", "That extension package could not be opened.");
228
+ throw new v("unsupported-extension", "That extension package could not be opened.");
230
229
  }
231
- }, W = async ({ zip: e, sizes: t, extensionId: n, version: r }) => {
232
- let i = x(await I({
230
+ }, U = async ({ zip: e, sizes: t, extensionId: n, version: r }) => {
231
+ let i = b(await F({
233
232
  zip: e,
234
233
  sizes: t,
235
234
  path: "extension/package.json",
236
235
  description: "The packaged extension manifest"
237
236
  }), "The packaged extension manifest");
238
- if ((typeof i.publisher == "string" && typeof i.name == "string" ? `${i.publisher}.${i.name}` : "").toLowerCase() !== n.toLowerCase() || i.version !== r) throw new y("integrity-failed", "The downloaded package does not match the selected Open VSX theme.");
237
+ if ((typeof i.publisher == "string" && typeof i.name == "string" ? `${i.publisher}.${i.name}` : "").toLowerCase() !== n.toLowerCase() || i.version !== r) throw new v("integrity-failed", "The downloaded package does not match the selected Open VSX theme.");
239
238
  return i;
240
- }, G = async ({ zip: t, sizes: n, manifest: r, fallbackName: i }) => {
241
- let a = w(r);
242
- if (a.length === 0 || a.length > g) throw new y("unsupported-extension", "That extension has no compatible color themes.");
239
+ }, W = async ({ zip: t, sizes: n, manifest: r, fallbackName: i }) => {
240
+ let a = C(r);
241
+ if (a.length === 0 || a.length > h) throw new v("unsupported-extension", "That extension has no compatible color themes.");
243
242
  let o = [];
244
243
  for (let r of a) {
245
- if (typeof r.path != "string") throw new y("unsupported-extension", "A contributed theme has no valid path.");
246
- let a = A(r.path), s = await L({
244
+ if (typeof r.path != "string") throw new v("unsupported-extension", "A contributed theme has no valid path.");
245
+ let a = k(r.path), s = await I({
247
246
  zip: t,
248
247
  sizes: n,
249
248
  path: a
250
- }), c = R(r.uiTheme), l = typeof r.label == "string" && r.label.trim() ? r.label.trim() : i;
249
+ }), c = L(r.uiTheme), l = typeof r.label == "string" && r.label.trim() ? r.label.trim() : i;
251
250
  o.push(e({
252
251
  source: JSON.stringify({
253
252
  ...s,
@@ -258,11 +257,11 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
258
257
  }));
259
258
  }
260
259
  return o;
261
- }, K = ({ description: e, name: t }) => {
260
+ }, G = ({ description: e, name: t }) => {
262
261
  let n = `${t} ${e}`;
263
262
  return /\b(?:color\s+scheme|theme)\b/i.test(n) && !/\b(?:file\s+icons?|product\s+icons?|icon\s+theme|icons?)\b/i.test(n);
264
- }, q = ({ fetchImpl: e = fetch } = {}) => {
265
- let n = E(e);
263
+ }, K = ({ fetchImpl: e = fetch } = {}) => {
264
+ let n = T(e);
266
265
  return {
267
266
  search: async (e) => {
268
267
  let t = e.trim();
@@ -271,20 +270,20 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
271
270
  message: "Search terms must be 100 characters or fewer."
272
271
  };
273
272
  try {
274
- let e = new URL(s);
273
+ let e = new URL(o);
275
274
  t && e.searchParams.set("query", t), e.searchParams.set("category", "Themes"), e.searchParams.set("sortBy", "downloadCount"), e.searchParams.set("sortOrder", "desc"), e.searchParams.set("size", "12");
276
275
  let r = await n({
277
276
  url: e,
278
- limit: c,
277
+ limit: s,
279
278
  failureMessage: "Open VSX search is unavailable right now.",
280
279
  tooLargeMessage: "Open VSX returned an unexpectedly large search response."
281
280
  }), i = JSON.parse(new TextDecoder().decode(r));
282
- if (!b(i) || !Array.isArray(i.extensions)) throw Error("invalid response");
281
+ if (!y(i) || !Array.isArray(i.extensions)) throw Error("invalid response");
283
282
  return {
284
283
  status: "success",
285
284
  themes: i.extensions.flatMap((e) => {
286
- let t = D(e);
287
- return t && K(t) ? [t] : [];
285
+ let t = E(e);
286
+ return t && G(t) ? [t] : [];
288
287
  }).slice(0, 12)
289
288
  };
290
289
  } catch {
@@ -296,24 +295,24 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
296
295
  },
297
296
  importTheme: async (e, r) => {
298
297
  try {
299
- let t = await B({
298
+ let t = await z({
300
299
  request: n,
301
300
  extensionId: e
302
301
  });
303
- await V({
302
+ await B({
304
303
  request: n,
305
304
  manifestUrl: t.manifestUrl
306
305
  });
307
- let { zip: i, sizes: a } = await U(await H({
306
+ let { zip: i, sizes: a } = await H(await V({
308
307
  request: n,
309
308
  ...t
310
309
  }));
311
310
  return {
312
311
  status: "success",
313
- theme: z(await G({
312
+ theme: R(await W({
314
313
  zip: i,
315
314
  sizes: a,
316
- manifest: await W({
315
+ manifest: await U({
317
316
  zip: i,
318
317
  sizes: a,
319
318
  extensionId: e,
@@ -323,7 +322,7 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
323
322
  }), r)
324
323
  };
325
324
  } catch (e) {
326
- return e instanceof t || e instanceof y ? {
325
+ return e instanceof t || e instanceof v ? {
327
326
  status: "error",
328
327
  error: {
329
328
  code: e.code,
@@ -341,4 +340,4 @@ var a = "https://open-vsx.org", o = "https://openvsx.eclipsecontent.org", s = `$
341
340
  };
342
341
  };
343
342
  //#endregion
344
- export { q as createOpenVsxThemeService };
343
+ export { K as createOpenVsxThemeService };
@@ -0,0 +1,24 @@
1
+ import type { SiteProjectId } from './projects.js';
2
+ export interface UiSiteLink {
3
+ label: string;
4
+ href: string;
5
+ }
6
+ export interface UiSiteHeaderProps {
7
+ /** The site this header belongs to. It is marked as current in the project menu. */
8
+ project: SiteProjectId;
9
+ /** Page links shown beside the appearance control, such as a user guide or releases. */
10
+ links?: readonly UiSiteLink[];
11
+ }
12
+ type __VLS_Slots = {
13
+ /** Replaces the System, Light, and Dark picker with a page-specific appearance control. */
14
+ default?(): unknown;
15
+ };
16
+ declare const __VLS_base: import("vue").DefineComponent<UiSiteHeaderProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<UiSiteHeaderProps> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
18
+ declare const _default: typeof __VLS_export;
19
+ export default _default;
20
+ type __VLS_WithSlots<T, S> = T & {
21
+ new (): {
22
+ $slots: S;
23
+ };
24
+ };
@@ -0,0 +1,35 @@
1
+ import type { ComputedRef } from 'vue';
2
+ export type SiteAppearanceChoice = 'system' | 'light' | 'dark';
3
+ export type SiteAppearance = 'light' | 'dark';
4
+ /** The choice is shared by every site in the family, so it must not change. */
5
+ export declare const SITE_APPEARANCE_KEY = "tool-site-theme";
6
+ /**
7
+ * Inline this in the document head, before the stylesheet paints, to avoid a flash
8
+ * of the wrong appearance. It applies the stored choice exactly as
9
+ * `applySiteAppearance` does and never throws.
10
+ */
11
+ export declare const siteThemeBootScript = "(function () {\n var root = document.documentElement, choice;\n try { choice = localStorage.getItem('tool-site-theme'); } catch (error) {}\n var dark = choice === 'dark';\n if (choice !== 'light' && choice !== 'dark') {\n try { dark = matchMedia('(prefers-color-scheme: dark)').matches; } catch (error) {}\n }\n root.classList.toggle('dark', dark);\n if (choice === 'light' || choice === 'dark') root.dataset.theme = choice;\n})();";
12
+ /** System removes the explicit override. Light and dark set it. */
13
+ export declare const applySiteAppearance: ({ root, choice, appearance }: {
14
+ root: HTMLElement;
15
+ choice: SiteAppearanceChoice;
16
+ appearance: SiteAppearance;
17
+ }) => void;
18
+ /**
19
+ * The shared System, Light, or Dark choice with the appearance it resolves to.
20
+ * Every call shares the stored choice, and other tabs and the operating system
21
+ * update it. Nothing reads storage or the browser until the component mounts, so
22
+ * server rendering and hydration see `system`. Storage failures leave the choice
23
+ * working for the current visit.
24
+ *
25
+ * By default the composable applies the appearance to `<html>`. Pass
26
+ * `applyToRoot: false` when the page owns the root, for example while an imported
27
+ * theme is active, and call `applySiteAppearance` yourself.
28
+ */
29
+ export declare const useSiteAppearance: ({ applyToRoot }?: {
30
+ applyToRoot?: boolean;
31
+ }) => {
32
+ choice: ComputedRef<SiteAppearanceChoice>;
33
+ appearance: ComputedRef<SiteAppearance>;
34
+ chooseAppearance: (value: SiteAppearanceChoice) => void;
35
+ };
@@ -0,0 +1,6 @@
1
+ export { default as UiSiteHeader } from './UiSiteHeader.vue';
2
+ export type { UiSiteHeaderProps, UiSiteLink } from './UiSiteHeader.vue';
3
+ export { SITE_PROJECTS } from './projects.js';
4
+ export type { SiteProject, SiteProjectId } from './projects.js';
5
+ export { applySiteAppearance, SITE_APPEARANCE_KEY, siteThemeBootScript, useSiteAppearance, } from './appearance.js';
6
+ export type { SiteAppearance, SiteAppearanceChoice } from './appearance.js';
@@ -0,0 +1,36 @@
1
+ declare const projects: readonly [{
2
+ readonly id: "annoterm";
3
+ readonly label: "annoterm";
4
+ readonly href: "https://filipgutica.github.io/annoterm/";
5
+ readonly repository: "https://github.com/filipgutica/annoterm";
6
+ }, {
7
+ readonly id: "wtree";
8
+ readonly label: "wtree";
9
+ readonly href: "https://filipgutica.github.io/wtree/";
10
+ readonly repository: "https://github.com/filipgutica/wtree";
11
+ }, {
12
+ readonly id: "devps";
13
+ readonly label: "devps";
14
+ readonly href: "https://filipgutica.github.io/devps/";
15
+ readonly repository: "https://github.com/filipgutica/devps";
16
+ }, {
17
+ readonly id: "workbench";
18
+ readonly label: "Workbench";
19
+ readonly href: "https://filipgutica.github.io/t3code/";
20
+ readonly repository: "https://github.com/filipgutica/t3code";
21
+ }, {
22
+ readonly id: "ui";
23
+ readonly label: "UI";
24
+ readonly href: "https://filipgutica.github.io/ui/";
25
+ readonly repository: "https://github.com/filipgutica/ui";
26
+ }];
27
+ export type SiteProjectId = (typeof projects)[number]['id'];
28
+ export interface SiteProject {
29
+ readonly id: SiteProjectId;
30
+ readonly label: string;
31
+ readonly href: string;
32
+ readonly repository: string;
33
+ }
34
+ /** Every site in the family, in the order the project menu lists them. */
35
+ export declare const SITE_PROJECTS: readonly SiteProject[];
36
+ export {};
package/dist/site.js ADDED
@@ -0,0 +1,177 @@
1
+ import { Fragment as e, computed as t, createCommentVNode as n, createElementBlock as r, createElementVNode as i, defineComponent as a, onBeforeUnmount as o, onMounted as s, openBlock as c, ref as l, renderList as u, renderSlot as d, toDisplayString as f, unref as p, useSlots as m, watch as h } from "vue";
2
+ import { tryOnMounted as g, usePreferredDark as _, useStorage as v } from "@vueuse/core";
3
+ //#region src/site/appearance.ts
4
+ var y = "tool-site-theme", b = (e) => e === "system" || e === "light" || e === "dark", x = `(function () {
5
+ var root = document.documentElement, choice;
6
+ try { choice = localStorage.getItem('${y}'); } catch (error) {}
7
+ var dark = choice === 'dark';
8
+ if (choice !== 'light' && choice !== 'dark') {
9
+ try { dark = matchMedia('(prefers-color-scheme: dark)').matches; } catch (error) {}
10
+ }
11
+ root.classList.toggle('dark', dark);
12
+ if (choice === 'light' || choice === 'dark') root.dataset.theme = choice;
13
+ })();`, S = ({ root: e, choice: t, appearance: n }) => {
14
+ e.classList.toggle("dark", n === "dark"), t === "system" ? delete e.dataset.theme : e.dataset.theme = t;
15
+ }, C = ({ applyToRoot: e = !0 } = {}) => {
16
+ let n = v(y, "system", void 0, {
17
+ initOnMounted: !0,
18
+ writeDefaults: !1,
19
+ onError: () => {},
20
+ serializer: {
21
+ read: (e) => b(e) ? e : "system",
22
+ write: (e) => e
23
+ }
24
+ }), r = _(), i = t(() => n.value), a = t(() => n.value === "system" ? r.value ? "dark" : "light" : n.value);
25
+ return e && g(() => {
26
+ h([i, a], () => S({
27
+ root: document.documentElement,
28
+ choice: i.value,
29
+ appearance: a.value
30
+ }), { immediate: !0 });
31
+ }), {
32
+ choice: i,
33
+ appearance: a,
34
+ chooseAppearance: (e) => {
35
+ b(e) && (n.value = e);
36
+ }
37
+ };
38
+ }, w = [
39
+ {
40
+ id: "annoterm",
41
+ label: "annoterm",
42
+ href: "https://filipgutica.github.io/annoterm/",
43
+ repository: "https://github.com/filipgutica/annoterm"
44
+ },
45
+ {
46
+ id: "wtree",
47
+ label: "wtree",
48
+ href: "https://filipgutica.github.io/wtree/",
49
+ repository: "https://github.com/filipgutica/wtree"
50
+ },
51
+ {
52
+ id: "devps",
53
+ label: "devps",
54
+ href: "https://filipgutica.github.io/devps/",
55
+ repository: "https://github.com/filipgutica/devps"
56
+ },
57
+ {
58
+ id: "workbench",
59
+ label: "Workbench",
60
+ href: "https://filipgutica.github.io/t3code/",
61
+ repository: "https://github.com/filipgutica/t3code"
62
+ },
63
+ {
64
+ id: "ui",
65
+ label: "UI",
66
+ href: "https://filipgutica.github.io/ui/",
67
+ repository: "https://github.com/filipgutica/ui"
68
+ }
69
+ ], T = { class: "fg-site-header" }, E = { class: "fg-site-header__inner" }, D = ["aria-label"], O = {
70
+ class: "fg-site-projects__menu fg-site-nav",
71
+ "aria-label": "Projects"
72
+ }, k = ["href", "aria-current"], A = { class: "fg-site-header__end" }, j = {
73
+ key: 0,
74
+ class: "fg-site-header__links",
75
+ "aria-label": "Site links"
76
+ }, M = ["href"], N = {
77
+ key: 0,
78
+ class: "fg-site-appearance",
79
+ role: "group",
80
+ "aria-label": "Color theme"
81
+ }, P = [
82
+ "aria-label",
83
+ "title",
84
+ "aria-pressed",
85
+ "onClick"
86
+ ], F = {
87
+ viewBox: "0 0 24 24",
88
+ width: "18",
89
+ height: "18",
90
+ fill: "none",
91
+ stroke: "currentColor",
92
+ "stroke-width": "1.5",
93
+ "aria-hidden": "true"
94
+ }, I = {
95
+ key: 2,
96
+ d: "M20.5 13.5A9 9 0 0 1 10.5 3a9 9 0 1 0 10 10.5Z"
97
+ }, L = /* @__PURE__ */ a({
98
+ __name: "UiSiteHeader",
99
+ props: {
100
+ project: {},
101
+ links: { default: () => [] }
102
+ },
103
+ setup(a) {
104
+ let h = [
105
+ {
106
+ value: "system",
107
+ label: "System"
108
+ },
109
+ {
110
+ value: "light",
111
+ label: "Light"
112
+ },
113
+ {
114
+ value: "dark",
115
+ label: "Dark"
116
+ }
117
+ ], g = t(() => w.find(({ id: e }) => e === a.project)?.label ?? a.project), _ = m().default ? void 0 : C(), v = l(!1), y = l(), b = l(), x = () => {
118
+ y.value && (y.value.open = !1);
119
+ }, S = (e) => {
120
+ e.key !== "Escape" || !y.value?.open || (x(), b.value?.focus());
121
+ }, L = (e) => {
122
+ e.relatedTarget instanceof Node && !y.value?.contains(e.relatedTarget) && x();
123
+ }, R = (e) => {
124
+ e.target instanceof Node && !y.value?.contains(e.target) && x();
125
+ };
126
+ return s(() => {
127
+ v.value = !0, document.addEventListener("pointerdown", R);
128
+ }), o(() => document.removeEventListener("pointerdown", R)), (t, o) => (c(), r("header", T, [i("div", E, [i("details", {
129
+ ref_key: "menu",
130
+ ref: y,
131
+ class: "fg-site-projects",
132
+ onKeydown: S,
133
+ onFocusout: L
134
+ }, [i("summary", {
135
+ ref_key: "trigger",
136
+ ref: b,
137
+ class: "fg-site-projects__trigger",
138
+ "aria-label": `Project: ${g.value}`
139
+ }, [i("span", null, f(g.value), 1), o[0] ||= i("svg", {
140
+ class: "fg-site-projects__chevron",
141
+ viewBox: "0 0 16 16",
142
+ width: "16",
143
+ height: "16",
144
+ fill: "none",
145
+ stroke: "currentColor",
146
+ "stroke-width": "1.5",
147
+ "aria-hidden": "true"
148
+ }, [i("path", { d: "m4 6 4 4 4-4" })], -1)], 8, D), i("nav", O, [(c(!0), r(e, null, u(p(w), (e) => (c(), r("a", {
149
+ key: e.id,
150
+ href: e.href,
151
+ "aria-current": e.id === a.project ? "page" : void 0
152
+ }, f(e.label), 9, k))), 128))])], 544), i("div", A, [a.links.length > 0 ? (c(), r("nav", j, [(c(!0), r(e, null, u(a.links, (e) => (c(), r("a", {
153
+ key: e.href,
154
+ href: e.href
155
+ }, f(e.label), 9, M))), 128))])) : n("", !0), d(t.$slots, "default", {}, () => [v.value && p(_) ? (c(), r("div", N, [(c(), r(e, null, u(h, (t) => i("button", {
156
+ key: t.value,
157
+ type: "button",
158
+ class: "fg-site-appearance__choice",
159
+ "aria-label": `${t.label} theme`,
160
+ title: `${t.label} theme`,
161
+ "aria-pressed": p(_).choice.value === t.value,
162
+ onClick: (e) => p(_).chooseAppearance(t.value)
163
+ }, [(c(), r("svg", F, [t.value === "system" ? (c(), r(e, { key: 0 }, [o[1] ||= i("rect", {
164
+ x: "3",
165
+ y: "4",
166
+ width: "18",
167
+ height: "13",
168
+ rx: "2"
169
+ }, null, -1), o[2] ||= i("path", { d: "M8 21h8m-4-4v4" }, null, -1)], 64)) : t.value === "light" ? (c(), r(e, { key: 1 }, [o[3] ||= i("circle", {
170
+ cx: "12",
171
+ cy: "12",
172
+ r: "4"
173
+ }, null, -1), o[4] ||= i("path", { d: "M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5" }, null, -1)], 64)) : (c(), r("path", I))]))], 8, P)), 64))])) : n("", !0)])])])]));
174
+ }
175
+ });
176
+ //#endregion
177
+ export { y as SITE_APPEARANCE_KEY, w as SITE_PROJECTS, L as UiSiteHeader, S as applySiteAppearance, x as siteThemeBootScript, C as useSiteAppearance };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filipgutica/ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "A compact Vue design system with Tailwind CSS and VS Code theme support",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -11,6 +11,8 @@
11
11
  "src/theme.css",
12
12
  "src/components.css",
13
13
  "src/components/*.css",
14
+ "src/site.css",
15
+ "src/themes/*.css",
14
16
  "README.md",
15
17
  "LICENSE"
16
18
  ],
@@ -23,12 +25,18 @@
23
25
  "types": "./dist/theme/index.d.ts",
24
26
  "import": "./dist/theme.js"
25
27
  },
28
+ "./site": {
29
+ "types": "./dist/site/index.d.ts",
30
+ "import": "./dist/site.js"
31
+ },
26
32
  "./open-vsx": {
27
33
  "types": "./dist/open-vsx/index.d.ts",
28
34
  "import": "./dist/open-vsx.js"
29
35
  },
30
36
  "./theme.css": "./src/theme.css",
31
- "./components.css": "./src/components.css"
37
+ "./components.css": "./src/components.css",
38
+ "./site.css": "./src/site.css",
39
+ "./themes/graphite-indigo.css": "./src/themes/graphite-indigo.css"
32
40
  },
33
41
  "scripts": {
34
42
  "build": "vite build && vue-tsc -p tsconfig.build.json --emitDeclarationOnly",
@@ -44,8 +52,10 @@
44
52
  "release:dry-run": "semantic-release --dry-run --no-ci",
45
53
  "test": "vitest run",
46
54
  "test:e2e": "playwright test",
55
+ "themes:build": "node scripts/graphite-indigo.mjs",
56
+ "themes:check": "node scripts/graphite-indigo.mjs --check",
47
57
  "typecheck": "vue-tsc -p tsconfig.json --noEmit",
48
- "verify": "pnpm lint && pnpm typecheck && pnpm docs:typecheck && pnpm test && pnpm test:e2e && pnpm build && pnpm docs:build && pnpm pack --dry-run && publint"
58
+ "verify": "pnpm lint && pnpm themes:check && pnpm typecheck && pnpm docs:typecheck && pnpm test && pnpm test:e2e && pnpm build && pnpm docs:build && pnpm pack --dry-run && publint"
49
59
  },
50
60
  "engines": {
51
61
  "node": ">=22.12"
@@ -1,5 +1,6 @@
1
1
  @layer components {
2
2
  .fg-tabs {
3
+ width: 100%;
3
4
  min-width: 0;
4
5
  font-family: var(--font-ui);
5
6
  font-size: var(--text-md);
@@ -8,8 +9,10 @@
8
9
 
9
10
  .fg-tabs__list {
10
11
  display: flex;
12
+ width: max-content;
11
13
  max-width: 100%;
12
14
  overflow-x: auto;
15
+ margin-block-end: var(--space-4);
13
16
  border-bottom: 1px solid var(--color-border-subtle);
14
17
  }
15
18
 
@@ -22,6 +25,7 @@
22
25
  color: var(--color-muted);
23
26
  background: transparent;
24
27
  font: inherit;
28
+ font-weight: var(--weight-medium);
25
29
  white-space: nowrap;
26
30
  cursor: pointer;
27
31
  }
@@ -30,7 +34,6 @@
30
34
  .fg-tabs__tab[aria-selected="true"] {
31
35
  border-bottom-color: var(--color-link);
32
36
  color: var(--color-text);
33
- font-weight: var(--weight-medium);
34
37
  }
35
38
 
36
39
  .fg-tabs__panel { min-width: 0; }
@@ -225,14 +225,14 @@
225
225
  }
226
226
 
227
227
  .fg-button[data-variant="danger"] {
228
- color: var(--color-error);
228
+ color: var(--color-status-text, var(--color-error));
229
229
  background: var(--color-error-surface);
230
230
  box-shadow: none;
231
231
  }
232
232
 
233
233
  .fg-button[data-variant="danger"]:hover:not(:disabled) {
234
234
  border-color: var(--color-error);
235
- color: var(--color-error);
235
+ color: var(--color-status-text, var(--color-error));
236
236
  background: var(--color-error-surface);
237
237
  box-shadow: none;
238
238
  }
@@ -522,11 +522,11 @@
522
522
  line-height: 1.25;
523
523
  }
524
524
 
525
- .fg-badge[data-tone="info"] { color: var(--color-info); background: var(--color-info-surface); }
526
- .fg-badge[data-tone="success"] { color: var(--color-success); background: var(--color-success-surface); }
527
- .fg-badge[data-tone="warning"] { color: var(--color-warning); background: var(--color-warning-surface); }
525
+ .fg-badge[data-tone="info"] { color: var(--color-status-text, var(--color-info)); background: var(--color-info-surface); }
526
+ .fg-badge[data-tone="success"] { color: var(--color-status-text, var(--color-success)); background: var(--color-success-surface); }
527
+ .fg-badge[data-tone="warning"] { color: var(--color-status-text, var(--color-warning)); background: var(--color-warning-surface); }
528
528
  .fg-badge[data-tone="danger"],
529
- .fg-badge[data-tone="error"] { color: var(--color-error); background: var(--color-error-surface); }
529
+ .fg-badge[data-tone="error"] { color: var(--color-status-text, var(--color-error)); background: var(--color-error-surface); }
530
530
 
531
531
  .fg-alert {
532
532
  display: flex;
@@ -545,7 +545,7 @@
545
545
  .fg-alert[data-tone="danger"],
546
546
  .fg-alert[data-tone="error"] {
547
547
  border-color: var(--color-error);
548
- color: var(--color-error);
548
+ color: var(--color-status-text, var(--color-error));
549
549
  background: var(--color-error-surface);
550
550
  }
551
551
 
package/src/site.css ADDED
@@ -0,0 +1,169 @@
1
+ /* Shared chrome and page helpers for the project sites. Use with
2
+ * @filipgutica/ui/themes/graphite-indigo.css, which supplies the type, space, and
3
+ * page-measure roles this file reads. Nothing here targets site-specific markup. */
4
+ @layer components {
5
+ /* A centered frame. The header uses the same edges as the page below it.
6
+ * Set --site-frame on the header or a frame to change its width. */
7
+ .fg-site-frame,
8
+ .fg-site-header__inner {
9
+ width: min(100%, var(--site-frame, var(--page-max)));
10
+ margin-inline: auto;
11
+ padding-inline: var(--site-gutter, var(--space-4));
12
+ }
13
+ .fg-site-frame--wide { --site-frame: var(--page-wide); }
14
+
15
+ .fg-site-header {
16
+ position: relative;
17
+ z-index: 30;
18
+ border-bottom: 1px solid var(--color-border-subtle);
19
+ background: var(--color-bg);
20
+ color: var(--color-text);
21
+ font-family: var(--font-ui);
22
+ font-size: var(--text-md);
23
+ line-height: var(--leading);
24
+ }
25
+ .fg-site-header__inner {
26
+ display: flex;
27
+ min-height: var(--space-16);
28
+ flex-wrap: wrap;
29
+ align-items: center;
30
+ gap: var(--space-2) var(--space-4);
31
+ padding-block: var(--space-2);
32
+ }
33
+ .fg-site-header__end {
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ align-items: center;
37
+ gap: var(--space-2) var(--space-8);
38
+ margin-inline-start: auto;
39
+ }
40
+ .fg-site-header__links { display: flex; align-items: center; gap: var(--space-6); }
41
+ .fg-site-header__links a {
42
+ display: inline-flex;
43
+ min-height: var(--control-lg);
44
+ align-items: center;
45
+ color: var(--color-muted);
46
+ font-size: var(--text-sm);
47
+ text-decoration: none;
48
+ }
49
+ .fg-site-header__links a:hover { color: var(--color-text); }
50
+
51
+ .fg-site-projects { position: relative; }
52
+ .fg-site-projects__trigger {
53
+ display: inline-flex;
54
+ min-height: var(--control-lg);
55
+ align-items: center;
56
+ gap: var(--space-1);
57
+ padding-inline: var(--space-3);
58
+ border-radius: var(--radius-control);
59
+ color: var(--color-text);
60
+ font-weight: var(--weight-semibold);
61
+ list-style: none;
62
+ cursor: pointer;
63
+ user-select: none;
64
+ }
65
+ .fg-site-projects__trigger::-webkit-details-marker { display: none; }
66
+ .fg-site-projects__trigger:hover,
67
+ .fg-site-projects[open] > .fg-site-projects__trigger { background: var(--color-row-hover); }
68
+ .fg-site-projects__chevron { transition: transform var(--duration-fast) ease; }
69
+ .fg-site-projects[open] .fg-site-projects__chevron { transform: rotate(180deg); }
70
+ .fg-site-projects__menu {
71
+ position: absolute;
72
+ z-index: 1;
73
+ top: 100%;
74
+ left: 0;
75
+ min-width: 14rem;
76
+ margin-top: var(--space-1);
77
+ padding: var(--space-1);
78
+ border-radius: var(--radius-panel);
79
+ background: var(--color-surface);
80
+ box-shadow: var(--shadow-dialog);
81
+ }
82
+
83
+ /* Route rows. Hover is neutral, and the current page keeps its selected fill on
84
+ * hover. Only aria-current="page" is styled: an in-page section marker
85
+ * (aria-current="location") must look different from the current page. */
86
+ .fg-site-nav a {
87
+ display: flex;
88
+ min-height: var(--row);
89
+ align-items: center;
90
+ padding-inline: var(--space-3);
91
+ border-radius: var(--radius-control);
92
+ color: var(--color-muted);
93
+ font-size: var(--text-md);
94
+ text-decoration: none;
95
+ transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease;
96
+ }
97
+ .fg-site-nav a:hover { background: var(--color-row-hover); color: var(--color-text); }
98
+ .fg-site-nav a[aria-current="page"] {
99
+ background: var(--color-row-selected);
100
+ box-shadow: inset 2px 0 0 var(--color-accent);
101
+ color: var(--color-text);
102
+ font-weight: var(--weight-medium);
103
+ }
104
+
105
+ .fg-site-appearance {
106
+ display: inline-flex;
107
+ flex: none;
108
+ border: 1px solid var(--color-border-subtle);
109
+ border-radius: var(--radius-control);
110
+ }
111
+ .fg-site-appearance__choice {
112
+ display: inline-flex;
113
+ width: var(--control-lg);
114
+ min-height: var(--control-lg);
115
+ align-items: center;
116
+ justify-content: center;
117
+ padding: 0;
118
+ border: 0;
119
+ color: var(--color-muted);
120
+ background: transparent;
121
+ font: inherit;
122
+ cursor: pointer;
123
+ }
124
+ .fg-site-appearance__choice + .fg-site-appearance__choice { border-left: 1px solid var(--color-border-subtle); }
125
+ .fg-site-appearance__choice:first-child { border-radius: var(--radius-control) 0 0 var(--radius-control); }
126
+ .fg-site-appearance__choice:last-child { border-radius: 0 var(--radius-control) var(--radius-control) 0; }
127
+ .fg-site-appearance__choice:hover { color: var(--color-text); background: var(--color-row-hover); }
128
+ .fg-site-appearance__choice[aria-pressed="true"] { color: var(--color-text); background: var(--color-row-selected); }
129
+
130
+ .fg-site-header a:focus-visible,
131
+ .fg-site-projects__trigger:focus-visible,
132
+ .fg-site-appearance__choice:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
133
+ .fg-site-appearance__choice:focus-visible { outline-offset: -2px; }
134
+ .fg-site-nav a:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
135
+
136
+ /* Type roles for article content. Prose reads at --text-reading. Components set
137
+ * their own size, and controls and dense content such as tables belong at --text-md.
138
+ * Zero-specificity heading selectors keep component and page styles in charge. */
139
+ .fg-site-prose { font-size: var(--text-reading); line-height: var(--leading); }
140
+ .fg-site-prose :where(h1, h2, h3, h4) {
141
+ margin: 0;
142
+ font-weight: var(--weight-semibold);
143
+ line-height: var(--leading-tight);
144
+ letter-spacing: var(--tracking-tight);
145
+ text-wrap: balance;
146
+ }
147
+ .fg-site-prose :where(h1) { font-size: var(--text-3xl); }
148
+ .fg-site-prose :where(h2) { font-size: var(--text-xl); }
149
+ .fg-site-prose :where(h3, h4) { font-size: var(--text-lg); }
150
+ .fg-site-prose :where(p, ul, ol) { max-width: var(--measure); margin: 0; }
151
+ .fg-site-prose :where(p, ul, ol) + :where(p, ul, ol) { margin-top: var(--space-4); }
152
+ .fg-site-prose :where(p a, li a, dd a) { text-decoration: underline; text-underline-offset: 0.18em; }
153
+ .fg-site-meta { color: var(--color-muted); font-size: var(--text-sm); }
154
+
155
+ @media (min-width: 681px) {
156
+ .fg-site-frame,
157
+ .fg-site-header__inner { padding-inline: var(--site-gutter, var(--space-6)); }
158
+ }
159
+ @media (max-width: 680px) {
160
+ .fg-site-prose :where(h1) { font-size: var(--text-2xl); }
161
+ .fg-site-header__end { gap: var(--space-2) var(--space-4); }
162
+ .fg-site-header__links { gap: var(--space-4); }
163
+ .fg-site-projects__menu a { min-height: var(--control-lg); }
164
+ }
165
+ @media (prefers-reduced-motion: reduce) {
166
+ .fg-site-projects__chevron,
167
+ .fg-site-nav a { transition: none; }
168
+ }
169
+ }
@@ -0,0 +1,111 @@
1
+ /* Generated from themes/graphite-indigo.json by scripts/graphite-indigo.mjs. Do not edit.
2
+ * Opt-in Graphite Indigo roles for @filipgutica/ui. Import after theme.css.
3
+ * Dark mode uses the same .dark class as theme.css. Keep exactly one :root.dark block:
4
+ * sites copy it into a <noscript> prefers-color-scheme rule. */
5
+
6
+ :root {
7
+ color-scheme: light;
8
+ --color-bg: #f4f5f8;
9
+ --color-surface: #e7e8ec;
10
+ --color-surface-raised: #ffffff;
11
+ --color-surface-muted: #e4e6eb;
12
+ --color-text: #222326;
13
+ --color-muted: #5d626c;
14
+ --color-link: #075fc7;
15
+ --color-accent: #6359a7;
16
+ --color-focus: #075fc7;
17
+ --color-border: #777d88;
18
+ --color-border-subtle: #d8dbe2;
19
+ --color-button-bg: var(--color-accent);
20
+ --color-button-text: #ffffff;
21
+ --color-button-hover: #554b94;
22
+ --color-input-bg: var(--color-surface-raised);
23
+ --color-input-text: var(--color-text);
24
+ --color-input-border: var(--color-border);
25
+ --color-input-placeholder: var(--color-muted);
26
+ --color-row-hover: #dfe1e7;
27
+ --color-row-selected: #ffffff;
28
+ --color-row-selected-text: var(--color-text);
29
+ --color-code: var(--color-surface);
30
+ --color-code-text: var(--color-text);
31
+ --color-syntax-comment: #626772;
32
+ --color-syntax-keyword: #715b42;
33
+ --color-syntax-string: #346347;
34
+ --color-syntax-number: #86532e;
35
+ --color-syntax-function: var(--color-syntax-keyword);
36
+ --color-syntax-type: var(--color-syntax-number);
37
+ --color-syntax-variable: var(--color-code-text);
38
+ --color-syntax-operator: var(--color-muted);
39
+ --color-syntax-punctuation: var(--color-muted);
40
+ --color-syntax-tag: var(--color-syntax-keyword);
41
+ --color-syntax-attribute: var(--color-syntax-number);
42
+ --color-error: #c7372d;
43
+ --color-warning: #86532e;
44
+ --color-success: #1f8a5b;
45
+ --color-info: #175cd3;
46
+ --color-progress: var(--color-accent);
47
+ --color-error-surface: color-mix(in srgb, var(--color-error) 10%, var(--color-bg));
48
+ --color-warning-surface: color-mix(in srgb, var(--color-warning) 12%, var(--color-bg));
49
+ --color-success-surface: color-mix(in srgb, var(--color-success) 11%, var(--color-bg));
50
+ --color-info-surface: color-mix(in srgb, var(--color-info) 10%, var(--color-bg));
51
+ --shadow-dialog: 0 8px 24px rgb(16 16 24 / 12%), 0 0 0 1px var(--color-border-subtle);
52
+ --color-status-text: var(--color-text);
53
+ --text-3xl: 32px;
54
+ --text-reading: 16px;
55
+ --text-display: 56px;
56
+ --text-display-sm: 38px;
57
+ --tracking-tight: -0.01em;
58
+ --space-20: 80px;
59
+ --space-24: 96px;
60
+ --row: 36px;
61
+ --page-wide: 1120px;
62
+ }
63
+
64
+ :root.dark {
65
+ color-scheme: dark;
66
+ --color-bg: #151619;
67
+ --color-surface: #191a1d;
68
+ --color-surface-raised: #212226;
69
+ --color-surface-muted: #303238;
70
+ --color-text: #f4f5f8;
71
+ --color-muted: #b0b5bf;
72
+ --color-link: #58a6ff;
73
+ --color-accent: #a99ce2;
74
+ --color-focus: #58a6ff;
75
+ --color-border: #818894;
76
+ --color-border-subtle: #3b3e45;
77
+ --color-button-bg: var(--color-accent);
78
+ --color-button-text: #151619;
79
+ --color-button-hover: #b7ace9;
80
+ --color-input-bg: var(--color-surface-raised);
81
+ --color-input-text: var(--color-text);
82
+ --color-input-border: var(--color-border);
83
+ --color-input-placeholder: var(--color-muted);
84
+ --color-row-hover: #25272d;
85
+ --color-row-selected: #38334f;
86
+ --color-row-selected-text: var(--color-text);
87
+ --color-code: var(--color-surface);
88
+ --color-code-text: var(--color-text);
89
+ --color-syntax-comment: #a0a6b1;
90
+ --color-syntax-keyword: #d8c2a5;
91
+ --color-syntax-string: #a8bd9c;
92
+ --color-syntax-number: #d5b98c;
93
+ --color-syntax-function: var(--color-syntax-keyword);
94
+ --color-syntax-type: var(--color-syntax-number);
95
+ --color-syntax-variable: var(--color-code-text);
96
+ --color-syntax-operator: var(--color-muted);
97
+ --color-syntax-punctuation: var(--color-muted);
98
+ --color-syntax-tag: var(--color-syntax-keyword);
99
+ --color-syntax-attribute: var(--color-syntax-number);
100
+ --color-error: #f0776c;
101
+ --color-warning: #e2a54b;
102
+ --color-success: #4cc38a;
103
+ --color-info: #84adff;
104
+ --color-progress: var(--color-accent);
105
+ --color-error-surface: color-mix(in srgb, var(--color-error) 10%, var(--color-bg));
106
+ --color-warning-surface: color-mix(in srgb, var(--color-warning) 12%, var(--color-bg));
107
+ --color-success-surface: color-mix(in srgb, var(--color-success) 11%, var(--color-bg));
108
+ --color-info-surface: color-mix(in srgb, var(--color-info) 10%, var(--color-bg));
109
+ --shadow-dialog: 0 8px 24px rgb(0 0 0 / 45%), 0 0 0 1px var(--color-border);
110
+ --color-status-text: var(--color-text);
111
+ }