@aethervtt/shard-ui 0.0.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.
@@ -0,0 +1,124 @@
1
+ const UI_SHADERS = {
2
+ "shard::ui": `
3
+ import shard::color::linear_to_srgb;
4
+
5
+ struct UiView {
6
+ /** Target width and height in pixels, then their inverses. */
7
+ size: vec4f,
8
+ }
9
+
10
+ struct Quad {
11
+ /** x, y (top left), width, height, in target pixels. */
12
+ rect: vec4f,
13
+ /** u0, v0, u1, v1. */
14
+ uv: vec4f,
15
+ /** Fill, tint, or glyph color: premultiplied. */
16
+ color: vec4f,
17
+ border_color: vec4f,
18
+ /** Corner radii: top left, top right, bottom right, bottom left. */
19
+ radius: vec4f,
20
+ /** x0, y0, x1, y1 in target pixels: fragments outside are cut. */
21
+ clip: vec4f,
22
+ border: f32,
23
+ /** 0 box, 1 image (straight alpha), 2 image (premultiplied), 3 MSDF glyph. */
24
+ kind: u32,
25
+ /** Glyphs: distance range in atlas pixels. */
26
+ range: f32,
27
+ /** Rotation around the rect's center (arrows). */
28
+ angle: f32,
29
+ }
30
+
31
+ @group(0) @binding(0) var<uniform> ui_view: UiView;
32
+ @group(0) @binding(1) var<storage, read> quads: array<Quad>;
33
+ @group(1) @binding(0) var ui_texture: texture_2d<f32>;
34
+ @group(1) @binding(1) var ui_sampler: sampler;
35
+
36
+ struct UiOutput {
37
+ @builtin(position) clip: vec4f,
38
+ /** Offset from the rect's center in pixels (unrotated), for the box distance. */
39
+ @location(0) local: vec2f,
40
+ @location(1) uv: vec2f,
41
+ @location(2) @interpolate(flat) quad: u32,
42
+ }
43
+
44
+ fn corner(k: u32) -> vec2f {
45
+ let c = array<vec2f, 6>(vec2f(0.0, 0.0), vec2f(0.0, 1.0), vec2f(1.0, 0.0), vec2f(1.0, 0.0), vec2f(0.0, 1.0), vec2f(1.0, 1.0));
46
+ return c[k];
47
+ }
48
+
49
+ @vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> UiOutput {
50
+ let q = quads[i];
51
+ let c = corner(v);
52
+ let half = q.rect.zw * 0.5;
53
+ // One pixel of margin so the antialiased edge isn't cut.
54
+ let grow = select(vec2f(1.0), vec2f(0.0), q.kind == 3u);
55
+ let local = (c * 2.0 - 1.0) * (half + grow);
56
+ let cs = cos(q.angle);
57
+ let sn = sin(q.angle);
58
+ let p = q.rect.xy + half + vec2f(local.x * cs - local.y * sn, local.x * sn + local.y * cs);
59
+ var out: UiOutput;
60
+ out.clip = vec4f(p.x * ui_view.size.z * 2.0 - 1.0, 1.0 - p.y * ui_view.size.w * 2.0, 0.5, 1.0);
61
+ out.local = local;
62
+ let f = clamp((local + half) / max(q.rect.zw, vec2f(1e-6)), vec2f(0.0), vec2f(1.0));
63
+ out.uv = mix(q.uv.xy, q.uv.zw, f);
64
+ out.quad = i;
65
+ return out;
66
+ }
67
+
68
+ /** Signed distance to a box of half size b with per-corner radii (tl, tr, br, bl), y down. */
69
+ fn rounded_box(p: vec2f, b: vec2f, r: vec4f) -> f32 {
70
+ let radius = select(r.xw, r.yz, p.x > 0.0);
71
+ let k = select(radius.x, radius.y, p.y > 0.0);
72
+ let q = abs(p) - b + vec2f(k);
73
+ return min(max(q.x, q.y), 0.0) + length(max(q, vec2f(0.0))) - k;
74
+ }
75
+
76
+ fn median(c: vec3f) -> f32 {
77
+ return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
78
+ }
79
+
80
+ @fragment fn fs(in: UiOutput) -> @location(0) vec4f {
81
+ // Sampled up front: derivatives need uniform control flow.
82
+ let texel = textureSample(ui_texture, ui_sampler, in.uv);
83
+ let uv_width = fwidth(in.uv);
84
+ let q = quads[in.quad];
85
+ // In the fragment stage, position is the pixel's center in target pixels.
86
+ let frag = in.clip;
87
+ if (frag.x < q.clip.x || frag.y < q.clip.y || frag.x >= q.clip.z || frag.y >= q.clip.w) {
88
+ discard;
89
+ }
90
+ let half = q.rect.zw * 0.5;
91
+ var c: vec4f;
92
+ if (q.kind == 3u) {
93
+ let unit_range = vec2f(q.range) / vec2f(textureDimensions(ui_texture));
94
+ let screen_px = max(0.5 * dot(unit_range, 1.0 / uv_width), 1.0);
95
+ let fill = clamp(screen_px * (median(texel.rgb) - 0.5) + 0.5, 0.0, 1.0);
96
+ c = q.color * fill;
97
+ } else {
98
+ let r = min(q.radius, vec4f(min(half.x, half.y)));
99
+ let d = rounded_box(in.local, half, r);
100
+ let edge = clamp(0.5 - d, 0.0, 1.0);
101
+ if (q.kind == 0u) {
102
+ var inside = edge;
103
+ if (q.border > 0.0) {
104
+ let inner = rounded_box(in.local, max(half - vec2f(q.border), vec2f(0.0)), max(r - vec4f(q.border), vec4f(0.0)));
105
+ inside = clamp(0.5 - inner, 0.0, 1.0);
106
+ }
107
+ c = q.color * inside + q.border_color * max(edge - inside, 0.0);
108
+ } else {
109
+ var t = texel;
110
+ if (q.kind == 1u) { t = vec4f(t.rgb * t.a, t.a); }
111
+ c = t * q.color * edge;
112
+ }
113
+ }
114
+ if (c.a <= 0.0) { discard; }
115
+ @if(!SRGB_TARGET) {
116
+ let rgb = c.rgb / c.a;
117
+ c = vec4f(linear_to_srgb(rgb) * c.a, c.a);
118
+ }
119
+ return c;
120
+ }`
121
+ };
122
+ export {
123
+ UI_SHADERS
124
+ };
@@ -0,0 +1,45 @@
1
+ import type { AssetRef } from '@aethervtt/shard-core';
2
+ import type { UiThemeAsset } from './theme.js';
3
+ /** Interaction bits a style's state variants key on. */
4
+ export declare const State: {
5
+ readonly On: 1;
6
+ readonly Focused: 2;
7
+ readonly Hovered: 4;
8
+ readonly Pressed: 8;
9
+ readonly Disabled: 16;
10
+ };
11
+ /** A node's look after its theme: what render, hit testing, and describe use. */
12
+ export declare class ResolvedStyle {
13
+ background: Float32Array<ArrayBuffer>;
14
+ borderColor: Float32Array<ArrayBuffer>;
15
+ borderWidth: number;
16
+ radius: Float32Array<ArrayBuffer>;
17
+ opacity: number;
18
+ size: number;
19
+ color: Float32Array<ArrayBuffer>;
20
+ font: AssetRef<'Font'> | null;
21
+ align: number;
22
+ wrap: boolean;
23
+ lineHeight: number;
24
+ }
25
+ /** Column views of one table's UiStyle and UiText rows (either may be absent). */
26
+ export interface StyleColumns {
27
+ background?: Float32Array;
28
+ borderColor?: Float32Array;
29
+ borderWidth?: Float32Array;
30
+ radius?: Float32Array;
31
+ opacity?: Float32Array;
32
+ size?: Float32Array;
33
+ color?: Float32Array;
34
+ font?: (AssetRef<'Font'> | null)[];
35
+ align?: Uint8Array;
36
+ wrap?: Uint8Array;
37
+ lineHeight?: Float32Array;
38
+ }
39
+ /**
40
+ * Resolves a node's look: entity fields that differ from their default win, then the theme
41
+ * style (with its state variants), then the defaults. `row` indexes the columns.
42
+ */
43
+ export declare function resolveStyle(out: ResolvedStyle, c: StyleColumns, row: number, theme: UiThemeAsset | undefined, style: string, state: number): ResolvedStyle;
44
+ /** The theme name a style lookup failed on, or undefined when it exists (or none is named). */
45
+ export declare function missingStyle(theme: UiThemeAsset | undefined, style: string): string | undefined;
package/dist/style.js ADDED
@@ -0,0 +1,111 @@
1
+ const State = { On: 1, Focused: 2, Hovered: 4, Pressed: 8, Disabled: 16 };
2
+ const STATE_NAMES = ["on", "focused", "hovered", "pressed", "disabled"];
3
+ class ResolvedStyle {
4
+ background = new Float32Array(4);
5
+ borderColor = new Float32Array(4);
6
+ borderWidth = 0;
7
+ radius = new Float32Array(4);
8
+ opacity = 1;
9
+ size = 16;
10
+ color = new Float32Array([1, 1, 1, 1]);
11
+ font = null;
12
+ align = 0;
13
+ wrap = true;
14
+ lineHeight = 1.2;
15
+ }
16
+ const ALIGNS = ["start", "center", "end"];
17
+ const DEFAULTS = {
18
+ background: [0, 0, 0, 0],
19
+ borderColor: [0, 0, 0, 0],
20
+ borderWidth: 0,
21
+ radius: [0, 0, 0, 0],
22
+ opacity: 1,
23
+ size: 16,
24
+ color: [1, 1, 1, 1],
25
+ align: 0,
26
+ wrap: 1,
27
+ lineHeight: 1.2
28
+ };
29
+ function sameVec(a, o, b) {
30
+ for (let k = 0; k < b.length; k++) if (Math.fround(a[o + k]) !== Math.fround(b[k])) return false;
31
+ return true;
32
+ }
33
+ function themeValue(theme, style, state, key) {
34
+ if (!theme || style === "") return void 0;
35
+ let value;
36
+ const base = theme.styles.get(style);
37
+ if (base && key in base) value = base[key];
38
+ if (state !== 0) {
39
+ for (let s = 0; s < STATE_NAMES.length; s++) {
40
+ if (!(state & 1 << s)) continue;
41
+ const variant = theme.styles.get(`${style}:${STATE_NAMES[s]}`);
42
+ if (variant && key in variant) value = variant[key];
43
+ }
44
+ }
45
+ return value;
46
+ }
47
+ function pickVec(out, own, o, def, theme) {
48
+ if (own && !sameVec(own, o, def)) {
49
+ for (let k = 0; k < out.length; k++) out[k] = own[o + k];
50
+ } else if (Array.isArray(theme)) {
51
+ for (let k = 0; k < out.length; k++) out[k] = theme[k] ?? def[k];
52
+ } else {
53
+ for (let k = 0; k < out.length; k++) out[k] = def[k];
54
+ }
55
+ }
56
+ function pickNum(own, def, theme) {
57
+ if (own !== void 0 && Math.fround(own) !== Math.fround(def)) return own;
58
+ return typeof theme === "number" ? theme : def;
59
+ }
60
+ function resolveStyle(out, c, row, theme, style, state) {
61
+ pickVec(
62
+ out.background,
63
+ c.background,
64
+ row * 4,
65
+ DEFAULTS.background,
66
+ themeValue(theme, style, state, "background")
67
+ );
68
+ pickVec(
69
+ out.borderColor,
70
+ c.borderColor,
71
+ row * 4,
72
+ DEFAULTS.borderColor,
73
+ themeValue(theme, style, state, "borderColor")
74
+ );
75
+ out.borderWidth = pickNum(
76
+ c.borderWidth?.[row],
77
+ DEFAULTS.borderWidth,
78
+ themeValue(theme, style, state, "borderWidth")
79
+ );
80
+ pickVec(out.radius, c.radius, row * 4, DEFAULTS.radius, themeValue(theme, style, state, "radius"));
81
+ out.opacity = pickNum(
82
+ c.opacity?.[row],
83
+ DEFAULTS.opacity,
84
+ themeValue(theme, style, state, "opacity")
85
+ );
86
+ out.size = pickNum(c.size?.[row], DEFAULTS.size, themeValue(theme, style, state, "size"));
87
+ pickVec(out.color, c.color, row * 4, DEFAULTS.color, themeValue(theme, style, state, "color"));
88
+ const ownFont = c.font?.[row];
89
+ const themeFont = themeValue(theme, style, state, "font");
90
+ out.font = ownFont ?? themeFont ?? theme?.font ?? null;
91
+ const themeAlign = themeValue(theme, style, state, "align");
92
+ out.align = c.align && c.align[row] !== DEFAULTS.align ? c.align[row] : typeof themeAlign === "string" ? Math.max(0, ALIGNS.indexOf(themeAlign)) : DEFAULTS.align;
93
+ const themeWrap = themeValue(theme, style, state, "wrap");
94
+ out.wrap = c.wrap && c.wrap[row] !== DEFAULTS.wrap ? c.wrap[row] !== 0 : typeof themeWrap === "boolean" ? themeWrap : true;
95
+ out.lineHeight = pickNum(
96
+ c.lineHeight?.[row],
97
+ DEFAULTS.lineHeight,
98
+ themeValue(theme, style, state, "lineHeight")
99
+ );
100
+ return out;
101
+ }
102
+ function missingStyle(theme, style) {
103
+ if (style === "" || !theme) return void 0;
104
+ return theme.styles.has(style) ? void 0 : style;
105
+ }
106
+ export {
107
+ ResolvedStyle,
108
+ State,
109
+ missingStyle,
110
+ resolveStyle
111
+ };
@@ -0,0 +1,20 @@
1
+ import { type ComponentDef, type Entity, type World } from '@aethervtt/shard-core';
2
+ import { App, type Plugin } from '@aethervtt/shard-runtime';
3
+ import { type Font } from '@aethervtt/shard-text';
4
+ /** Inter (latin), built once per test file. */
5
+ export declare function interFont(): Font;
6
+ export declare function uiApp(extra?: Plugin[]): Promise<{
7
+ app: App;
8
+ world: World;
9
+ frame: (n?: number) => void;
10
+ }>;
11
+ type Init = [ComponentDef, Record<string, unknown>?];
12
+ /** Spawns a node under `parent` (a UiNode is added if absent). */
13
+ export declare function node(world: World, parent: Entity | null, ...components: (ComponentDef | Init)[]): Entity;
14
+ /** A root at a reference size (headless, that's its size). */
15
+ export declare function root(world: World, width?: number, height?: number, fields?: Record<string, unknown>): Entity;
16
+ export declare function rect(world: World, e: Entity): number[];
17
+ export declare function addFont(world: World): import("@aethervtt/shard-core").AssetRef<"Font">;
18
+ export declare function mouse(world: World, x: number, y: number): void;
19
+ export declare function click(world: World, frame: () => void, x: number, y: number): void;
20
+ export {};
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { ChildOf } from "@aethervtt/shard-core";
5
+ import { injectInput, inputPlugin } from "@aethervtt/shard-input";
6
+ import { App } from "@aethervtt/shard-runtime";
7
+ import { Fonts, fontFromBytes } from "@aethervtt/shard-text";
8
+ import { TransformPlugin } from "@aethervtt/shard-transform";
9
+ import { UiLayout, UiNode, UiRoot } from "./components.js";
10
+ import { uiPlugin } from "./plugin.js";
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ let inter;
13
+ function interFont() {
14
+ inter ??= fontFromBytes(
15
+ new Uint8Array(readFileSync(resolve(here, "../../text/fixtures/Inter-Regular.ttf"))),
16
+ {
17
+ charset: "latin"
18
+ }
19
+ );
20
+ return inter;
21
+ }
22
+ async function uiApp(extra = []) {
23
+ const app = new App().addPlugin(TransformPlugin, inputPlugin(), uiPlugin, ...extra);
24
+ await app.init();
25
+ const world = app.world;
26
+ const frame = (n = 1) => {
27
+ for (let i = 0; i < n; i++) app.update(1 / 60);
28
+ };
29
+ return { app, world, frame };
30
+ }
31
+ function node(world, parent, ...components) {
32
+ const list = components.map((c) => Array.isArray(c) ? c : [c]);
33
+ if (!list.some(([def]) => def === UiNode)) list.unshift([UiNode, {}]);
34
+ const e = world.spawn(...list);
35
+ if (parent !== null) world.add(e, ChildOf, { parent });
36
+ return e;
37
+ }
38
+ function root(world, width = 800, height = 600, fields = {}) {
39
+ return world.spawn([UiRoot, { referenceSize: [width, height], ...fields }]);
40
+ }
41
+ function rect(world, e) {
42
+ const l = world.get(e, UiLayout);
43
+ return [l.x, l.y, l.width, l.height].map((v) => Math.round(v * 100) / 100);
44
+ }
45
+ function addFont(world) {
46
+ return world.resource(Fonts).add(interFont());
47
+ }
48
+ function mouse(world, x, y) {
49
+ injectInput(world, { type: "mouse-move", x, y, dx: 0, dy: 0 });
50
+ }
51
+ function click(world, frame, x, y) {
52
+ mouse(world, x, y);
53
+ frame();
54
+ injectInput(world, { mouse: "left", pressed: true });
55
+ frame();
56
+ injectInput(world, { mouse: "left", pressed: false });
57
+ frame();
58
+ }
59
+ export {
60
+ addFont,
61
+ click,
62
+ interFont,
63
+ mouse,
64
+ node,
65
+ rect,
66
+ root,
67
+ uiApp
68
+ };
@@ -0,0 +1,30 @@
1
+ import { AssetStore } from '@aethervtt/shard-assets';
2
+ import { type AnyField, type AssetRef, type FieldType } from '@aethervtt/shard-core';
3
+ /** Style fields a theme may set: every UiStyle field, and UiText's look (not its text). */
4
+ export declare const THEME_FIELDS: Readonly<Record<string, AnyField>>;
5
+ /** State variants a style can have (`"button:hovered"`), applied in this order over the base. */
6
+ export declare const THEME_STATES: readonly ['on', 'focused', 'hovered', 'pressed', 'disabled'];
7
+ /** One style: the fields it sets (values as components hold them: colors linear, refs resolved). */
8
+ export type ThemeStyle = Record<string, unknown>;
9
+ export declare const UiThemeSchema: import("@aethervtt/shard-core").ComponentDef<{
10
+ readonly font: FieldType<AssetRef<"Font"> | null, "object">;
11
+ readonly styles: FieldType<Record<string, ThemeStyle>, "object">;
12
+ }>;
13
+ /** A loaded theme: styles by name, with state variants kept separately. Hot reload bumps version. */
14
+ export declare class UiThemeAsset {
15
+ font: AssetRef<'Font'> | null;
16
+ /** Style name → fields; state variants under "name:state". */
17
+ styles: Map<string, ThemeStyle>;
18
+ version: number;
19
+ constructor(font: AssetRef<'Font'> | null, styles: Record<string, ThemeStyle>);
20
+ copyFrom(other: UiThemeAsset): void;
21
+ /** Builds a theme from its JSON form (`*.theme.json`). */
22
+ static fromJson(json: unknown, resolve?: (path: string) => AssetRef | undefined): UiThemeAsset;
23
+ }
24
+ export declare class UiThemeStore extends AssetStore<UiThemeAsset, 'UiTheme'> {
25
+ constructor();
26
+ }
27
+ export declare const UiThemes: import("@aethervtt/shard-core").ResourceDef<UiThemeStore>;
28
+ export declare const UiThemeAssetType: import("@aethervtt/shard-assets").AssetTypeDef<UiThemeAsset>;
29
+ /** `*.theme.json`: named styles for UI roots. */
30
+ export declare const UiThemeImporter: import("@aethervtt/shard-assets").ImporterDef;
package/dist/theme.js ADDED
@@ -0,0 +1,172 @@
1
+ import {
2
+ AssetStore,
3
+ defineAssetType,
4
+ defineDataAsset
5
+ } from "@aethervtt/shard-assets";
6
+ import {
7
+ cloneData,
8
+ defineResource,
9
+ defineSchema,
10
+ isPlainObject,
11
+ pointer,
12
+ ShardError,
13
+ t
14
+ } from "@aethervtt/shard-core";
15
+ import { UiStyle, UiText } from "./components.js";
16
+ const THEME_FIELDS = (() => {
17
+ const out = {};
18
+ for (const [name, field] of Object.entries(UiStyle.fields)) out[name] = field;
19
+ for (const [name, field] of Object.entries(UiText.fields)) {
20
+ if (name !== "text" && name !== "key" && name !== "params") out[name] = field;
21
+ }
22
+ return out;
23
+ })();
24
+ const THEME_STATES = ["on", "focused", "hovered", "pressed", "disabled"];
25
+ function unknownStyleField(path, key) {
26
+ return new ShardError("schema/unknown-field", `Unknown style field "${key}" at ${path}`, {
27
+ path,
28
+ hint: `A style sets ${Object.keys(THEME_FIELDS).join(", ")}.`
29
+ });
30
+ }
31
+ const STYLES_DESCRIPTION = 'Style name \u2192 fields (UiStyle and UiText: background, borderColor, borderWidth, radius, opacity, size, color, font, align, wrap, lineHeight). "name:hovered" (on, focused, hovered, pressed, disabled) overrides fields in that state.';
32
+ function stylesField() {
33
+ const base = t.json();
34
+ const convert = (json, toValue, ctx) => {
35
+ const out = {};
36
+ if (!isPlainObject(json)) return out;
37
+ for (const [name, style] of Object.entries(json)) {
38
+ if (!isPlainObject(style)) continue;
39
+ const s = {};
40
+ for (const [key, value] of Object.entries(style)) {
41
+ const field = THEME_FIELDS[key];
42
+ if (!field) continue;
43
+ s[key] = toValue ? field.fromJson(value, ctx) : field.toJson(value);
44
+ }
45
+ out[name] = s;
46
+ }
47
+ return out;
48
+ };
49
+ return {
50
+ ...base,
51
+ options: { description: STYLES_DESCRIPTION },
52
+ defaultValue: () => ({}),
53
+ read: (c, r) => cloneData(c[r]),
54
+ write: (c, r, v) => {
55
+ ;
56
+ c[r] = cloneData(v);
57
+ },
58
+ validate(json, path, errors, ctx) {
59
+ if (!isPlainObject(json)) {
60
+ errors.push(
61
+ new ShardError("schema/type-mismatch", `Expected an object of styles at ${path || "/"}`, {
62
+ path,
63
+ hint: 'Use { "panel": { "background": "#101820cc" } }.'
64
+ })
65
+ );
66
+ return;
67
+ }
68
+ for (const [name, style] of Object.entries(json)) {
69
+ const at = pointer(path, name);
70
+ const colon = name.indexOf(":");
71
+ const state = colon === -1 ? "" : name.slice(colon + 1);
72
+ if (colon === 0 || state && !THEME_STATES.includes(state)) {
73
+ errors.push(
74
+ new ShardError("ui/unknown-state", `"${name}" isn't a style or a style state`, {
75
+ path: at,
76
+ hint: `Name a style ("button") or a state of one: ${THEME_STATES.map((s) => `"button:${s}"`).join(", ")}.`
77
+ })
78
+ );
79
+ }
80
+ if (!isPlainObject(style)) {
81
+ errors.push(
82
+ new ShardError("schema/type-mismatch", `Style "${name}" must be an object`, {
83
+ path: at
84
+ })
85
+ );
86
+ continue;
87
+ }
88
+ for (const [key, value] of Object.entries(style)) {
89
+ const field = THEME_FIELDS[key];
90
+ if (!field) errors.push(unknownStyleField(pointer(at, key), key));
91
+ else field.validate(value, pointer(at, key), errors, ctx);
92
+ }
93
+ }
94
+ },
95
+ toJson: (v) => convert(v, false),
96
+ fromJson: (json, ctx) => convert(json, true, ctx),
97
+ jsonSchema: () => {
98
+ const properties = {};
99
+ for (const [key, field] of Object.entries(THEME_FIELDS)) properties[key] = field.jsonSchema();
100
+ return {
101
+ type: "object",
102
+ description: STYLES_DESCRIPTION,
103
+ additionalProperties: { type: "object", properties, additionalProperties: false },
104
+ propertyNames: { pattern: `^[^:]+(:(${THEME_STATES.join("|")}))?$` },
105
+ default: {}
106
+ };
107
+ }
108
+ };
109
+ }
110
+ const UiThemeSchema = defineSchema(
111
+ "ui/UiTheme",
112
+ {
113
+ font: t.handle("Font", { description: "Font for the theme's text (styles may pick others)." }),
114
+ styles: stylesField()
115
+ },
116
+ {
117
+ description: "Named node styles for a UI root (*.theme.json): nodes pick one with UiNode.style; fields a node sets win."
118
+ }
119
+ );
120
+ class UiThemeAsset {
121
+ font;
122
+ /** Style name → fields; state variants under "name:state". */
123
+ styles;
124
+ version = 0;
125
+ constructor(font, styles) {
126
+ this.font = font;
127
+ this.styles = new Map(Object.entries(styles));
128
+ }
129
+ copyFrom(other) {
130
+ this.font = other.font;
131
+ this.styles = other.styles;
132
+ this.version++;
133
+ }
134
+ /** Builds a theme from its JSON form (`*.theme.json`). */
135
+ static fromJson(json, resolve) {
136
+ const value = UiThemeSchema.deserialize(json);
137
+ const fix = (ref) => {
138
+ if (!ref) return null;
139
+ const resolved = ref.path ? resolve?.(ref.path) : void 0;
140
+ return resolved ? { type: ref.type, guid: resolved.guid, path: resolved.path } : ref;
141
+ };
142
+ for (const style of Object.values(value.styles)) {
143
+ if (style.font) style.font = fix(style.font);
144
+ }
145
+ return new UiThemeAsset(fix(value.font), value.styles);
146
+ }
147
+ }
148
+ class UiThemeStore extends AssetStore {
149
+ constructor() {
150
+ super("UiTheme");
151
+ }
152
+ }
153
+ const UiThemes = defineResource("ui/UiThemes", {
154
+ description: "Loaded UI themes by guid.",
155
+ init: () => new UiThemeStore()
156
+ });
157
+ const UiThemeAssetType = defineAssetType("UiTheme", {
158
+ store: UiThemes,
159
+ load: (artifact, ctx) => UiThemeAsset.fromJson(artifact.json, (path) => ctx.resolve(path)),
160
+ update: (existing, next) => existing.copyFrom(next)
161
+ });
162
+ const UiThemeImporter = defineDataAsset("UiTheme", UiThemeSchema, { extension: "theme" });
163
+ export {
164
+ THEME_FIELDS,
165
+ THEME_STATES,
166
+ UiThemeAsset,
167
+ UiThemeAssetType,
168
+ UiThemeImporter,
169
+ UiThemeSchema,
170
+ UiThemeStore,
171
+ UiThemes
172
+ };