aurasu 0.1.0 → 0.1.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.
@@ -0,0 +1,227 @@
1
+ import {
2
+ listUiContrastChoices,
3
+ listUiDensityChoices,
4
+ listUiThemeChoices,
5
+ } from './ui-theme.js';
6
+
7
+ function hasUiMethod(name) {
8
+ return Boolean(globalThis.aura?.ui) && typeof globalThis.aura.ui[name] === 'function';
9
+ }
10
+
11
+ function activeTheme() {
12
+ return hasUiMethod('getTheme') ? globalThis.aura.ui.getTheme() : null;
13
+ }
14
+
15
+ function cloneSettingsValue(value) {
16
+ if (Array.isArray(value)) {
17
+ return value.map((entry) => cloneSettingsValue(entry));
18
+ }
19
+ if (value && typeof value === 'object') {
20
+ const output = {};
21
+ for (const [key, entry] of Object.entries(value)) {
22
+ output[key] = cloneSettingsValue(entry);
23
+ }
24
+ return output;
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function normalizeOptions(options) {
30
+ return Array.isArray(options)
31
+ ? options
32
+ .filter((entry) => entry && typeof entry === 'object' && !Array.isArray(entry))
33
+ .map((entry) => ({
34
+ id: typeof entry.id === 'string' ? entry.id.trim() : '',
35
+ label: typeof entry.label === 'string' && entry.label.trim().length > 0 ? entry.label : String(entry.id || ''),
36
+ description: typeof entry.description === 'string' ? entry.description : null,
37
+ disabled: entry.disabled === true,
38
+ }))
39
+ .filter((entry) => entry.id.length > 0)
40
+ : [];
41
+ }
42
+
43
+ function renderInfoText(id, label = null, description = null) {
44
+ if (label) {
45
+ globalThis.aura.ui.text(`${id}-label`, {
46
+ text: label,
47
+ size: 12,
48
+ color: activeTheme()?.mutedTextColor || '#9db2cb',
49
+ });
50
+ }
51
+ if (description) {
52
+ globalThis.aura.ui.text(`${id}-description`, {
53
+ text: description,
54
+ size: 13,
55
+ color: activeTheme()?.mutedTextColor || '#9db2cb',
56
+ });
57
+ }
58
+ }
59
+
60
+ export function readViewState(nodeId) {
61
+ if (!hasUiMethod('getViewState')) return null;
62
+ return globalThis.aura.ui.getViewState(nodeId) || null;
63
+ }
64
+
65
+ export function wasViewClicked(nodeId) {
66
+ const state = readViewState(nodeId);
67
+ return state?.clicked === true || state?.activated === true;
68
+ }
69
+
70
+ export function readBooleanViewValue(nodeId, fallback = false) {
71
+ const state = readViewState(nodeId);
72
+ return typeof state?.value === 'boolean' ? state.value : fallback;
73
+ }
74
+
75
+ export function readNumberViewValue(nodeId, fallback = 0) {
76
+ const state = readViewState(nodeId);
77
+ return Number.isFinite(Number(state?.value)) ? Number(state.value) : fallback;
78
+ }
79
+
80
+ export function renderSegmentedControl({
81
+ id,
82
+ label = null,
83
+ description = null,
84
+ value = null,
85
+ options = [],
86
+ } = {}) {
87
+ if (!hasUiMethod('beginColumn') || !hasUiMethod('beginRow') || !hasUiMethod('button')) {
88
+ return {
89
+ ok: false,
90
+ reasonCode: 'ui_segmented_runtime_unavailable',
91
+ value,
92
+ };
93
+ }
94
+
95
+ const normalizedOptions = normalizeOptions(options);
96
+ if (typeof id !== 'string' || id.trim().length <= 0 || normalizedOptions.length <= 0) {
97
+ return {
98
+ ok: false,
99
+ reasonCode: 'ui_segmented_invalid_options',
100
+ value,
101
+ };
102
+ }
103
+
104
+ const theme = activeTheme();
105
+ const rootId = id.trim();
106
+ globalThis.aura.ui.beginColumn({
107
+ id: `${rootId}-group`,
108
+ gap: 6,
109
+ width: 'fill',
110
+ });
111
+ renderInfoText(rootId, label, description);
112
+ globalThis.aura.ui.beginRow({
113
+ id: `${rootId}-row`,
114
+ gap: 8,
115
+ width: 'fill',
116
+ });
117
+ for (const option of normalizedOptions) {
118
+ const active = option.id === value;
119
+ globalThis.aura.ui.button(`${rootId}-${option.id}`, {
120
+ label: option.label,
121
+ width: 'fill',
122
+ disabled: option.disabled === true,
123
+ background: active ? (theme?.buttonFocusColor || '#1e5b97') : (theme?.buttonColor || '#132132'),
124
+ borderColor: active ? (theme?.buttonTextColor || '#ffffff') : (theme?.panelBorderColor || '#314e68'),
125
+ });
126
+ }
127
+ globalThis.aura.ui.endRow();
128
+ globalThis.aura.ui.endColumn();
129
+
130
+ let nextValue = value;
131
+ const states = [];
132
+ for (const option of normalizedOptions) {
133
+ const state = readViewState(`${rootId}-${option.id}`);
134
+ if (state?.clicked === true || state?.activated === true) {
135
+ nextValue = option.id;
136
+ }
137
+ states.push({
138
+ id: option.id,
139
+ label: option.label,
140
+ active: option.id === nextValue,
141
+ clicked: state?.clicked === true,
142
+ focused: state?.focused === true,
143
+ disabled: state?.disabled === true,
144
+ });
145
+ }
146
+
147
+ return {
148
+ ok: true,
149
+ reasonCode: 'ui_segmented_rendered',
150
+ value: nextValue,
151
+ changed: nextValue !== value,
152
+ options: states,
153
+ };
154
+ }
155
+
156
+ export function renderActionStrip({ id, actions = [] } = {}) {
157
+ if (!hasUiMethod('beginRow') || !hasUiMethod('button')) {
158
+ return {
159
+ ok: false,
160
+ reasonCode: 'ui_action_strip_runtime_unavailable',
161
+ actionId: null,
162
+ };
163
+ }
164
+ const normalizedActions = normalizeOptions(actions);
165
+ if (typeof id !== 'string' || id.trim().length <= 0 || normalizedActions.length <= 0) {
166
+ return {
167
+ ok: false,
168
+ reasonCode: 'ui_action_strip_invalid_actions',
169
+ actionId: null,
170
+ };
171
+ }
172
+
173
+ const rootId = id.trim();
174
+ globalThis.aura.ui.beginRow({
175
+ id: `${rootId}-actions`,
176
+ gap: 8,
177
+ width: 'fill',
178
+ });
179
+ for (const action of normalizedActions) {
180
+ globalThis.aura.ui.button(`${rootId}-${action.id}`, {
181
+ label: action.label,
182
+ width: 'fill',
183
+ disabled: action.disabled === true,
184
+ });
185
+ }
186
+ globalThis.aura.ui.endRow();
187
+
188
+ let actionId = null;
189
+ const states = [];
190
+ for (const action of normalizedActions) {
191
+ const state = readViewState(`${rootId}-${action.id}`);
192
+ if (state?.clicked === true || state?.activated === true) {
193
+ actionId = action.id;
194
+ }
195
+ states.push({
196
+ id: action.id,
197
+ clicked: state?.clicked === true,
198
+ focused: state?.focused === true,
199
+ disabled: state?.disabled === true,
200
+ });
201
+ }
202
+
203
+ return {
204
+ ok: true,
205
+ reasonCode: 'ui_action_strip_rendered',
206
+ actionId,
207
+ states: cloneSettingsValue(states),
208
+ };
209
+ }
210
+
211
+ export function listThemeSegmentOptions() {
212
+ return listUiThemeChoices();
213
+ }
214
+
215
+ export function listDensitySegmentOptions() {
216
+ return listUiDensityChoices();
217
+ }
218
+
219
+ export function listContrastSegmentOptions() {
220
+ return listUiContrastChoices();
221
+ }
222
+
223
+ export function formatPercentFromTenth(value) {
224
+ const numeric = Number(value);
225
+ const clamped = Number.isFinite(numeric) ? Math.max(0, Math.min(10, numeric)) : 0;
226
+ return `${Math.round(clamped * 10)}%`;
227
+ }
@@ -0,0 +1,297 @@
1
+ export const UI_THEME_SCHEMA = 'aurajs.ui-theme.v1';
2
+ export const UI_PREFERENCES_SCHEMA = 'aurajs.ui-preferences.v1';
3
+
4
+ const UI_THEME_PRESETS = Object.freeze({
5
+ 'signal-dawn': Object.freeze({
6
+ id: 'signal-dawn',
7
+ label: 'Signal Dawn',
8
+ description: 'Warm amber callouts over slate control panels.',
9
+ theme: Object.freeze({
10
+ gap: 10,
11
+ padding: 12,
12
+ fontSize: 16,
13
+ lineHeight: 20,
14
+ buttonHeight: 38,
15
+ borderWidth: 1,
16
+ textColor: '#eef5ff',
17
+ mutedTextColor: '#9db2cb',
18
+ panelColor: '#0d1622',
19
+ panelBorderColor: '#314e68',
20
+ buttonColor: '#132132',
21
+ buttonHoverColor: '#203853',
22
+ buttonFocusColor: '#1e5b97',
23
+ buttonActiveColor: '#2183d6',
24
+ buttonTextColor: '#ffffff',
25
+ }),
26
+ }),
27
+ 'midnight-arcade': Object.freeze({
28
+ id: 'midnight-arcade',
29
+ label: 'Midnight Arcade',
30
+ description: 'Neon cyan and magenta over a darker cabinet shell.',
31
+ theme: Object.freeze({
32
+ gap: 10,
33
+ padding: 12,
34
+ fontSize: 16,
35
+ lineHeight: 20,
36
+ buttonHeight: 38,
37
+ borderWidth: 1,
38
+ textColor: '#f5f8ff',
39
+ mutedTextColor: '#a9b4de',
40
+ panelColor: '#090d18',
41
+ panelBorderColor: '#4d3f82',
42
+ buttonColor: '#101728',
43
+ buttonHoverColor: '#21304f',
44
+ buttonFocusColor: '#7a49ff',
45
+ buttonActiveColor: '#f062d0',
46
+ buttonTextColor: '#ffffff',
47
+ }),
48
+ }),
49
+ 'pine-night': Object.freeze({
50
+ id: 'pine-night',
51
+ label: 'Pine Night',
52
+ description: 'Forest greens and bright mint accents for readable HUDs.',
53
+ theme: Object.freeze({
54
+ gap: 10,
55
+ padding: 12,
56
+ fontSize: 16,
57
+ lineHeight: 20,
58
+ buttonHeight: 38,
59
+ borderWidth: 1,
60
+ textColor: '#eef7f2',
61
+ mutedTextColor: '#adc8bb',
62
+ panelColor: '#0b1713',
63
+ panelBorderColor: '#35584b',
64
+ buttonColor: '#13241e',
65
+ buttonHoverColor: '#1e3a31',
66
+ buttonFocusColor: '#2d7b63',
67
+ buttonActiveColor: '#4fb98c',
68
+ buttonTextColor: '#ffffff',
69
+ }),
70
+ }),
71
+ });
72
+
73
+ const UI_DENSITY_CHOICES = Object.freeze([
74
+ Object.freeze({ id: 'compact', label: 'Compact', description: 'Tighter gaps and shorter controls.' }),
75
+ Object.freeze({ id: 'cozy', label: 'Cozy', description: 'A roomier default for game settings screens.' }),
76
+ ]);
77
+
78
+ const UI_CONTRAST_CHOICES = Object.freeze([
79
+ Object.freeze({ id: 'standard', label: 'Standard', description: 'Default contrast and chrome.' }),
80
+ Object.freeze({ id: 'high', label: 'High', description: 'Brighter text and stronger borders.' }),
81
+ ]);
82
+
83
+ const DEFAULT_UI_PREFERENCES = Object.freeze({
84
+ schema: UI_PREFERENCES_SCHEMA,
85
+ themeId: 'signal-dawn',
86
+ density: 'cozy',
87
+ contrast: 'standard',
88
+ musicEnabled: true,
89
+ subtitlesEnabled: false,
90
+ masterVolume: 8,
91
+ reduceMotion: false,
92
+ });
93
+
94
+ function cloneUiValue(value) {
95
+ if (Array.isArray(value)) {
96
+ return value.map((entry) => cloneUiValue(entry));
97
+ }
98
+ if (value && typeof value === 'object') {
99
+ const output = {};
100
+ for (const [key, entry] of Object.entries(value)) {
101
+ output[key] = cloneUiValue(entry);
102
+ }
103
+ return output;
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function clampNumber(value, min, max, fallback) {
109
+ const numeric = Number(value);
110
+ if (!Number.isFinite(numeric)) return fallback;
111
+ return Math.max(min, Math.min(max, numeric));
112
+ }
113
+
114
+ function normalizeChoice(value, choices, fallback) {
115
+ const normalized = typeof value === 'string' ? value.trim() : '';
116
+ if (!normalized) return fallback;
117
+ return choices.some((entry) => entry.id === normalized) ? normalized : fallback;
118
+ }
119
+
120
+ function normalizeBoolean(value, fallback) {
121
+ if (value === true) return true;
122
+ if (value === false) return false;
123
+ return fallback;
124
+ }
125
+
126
+ function ensureUiBucket(appState) {
127
+ if (!appState || typeof appState !== 'object' || Array.isArray(appState)) {
128
+ throw new Error('Expected appState to be a mutable object.');
129
+ }
130
+ if (!appState.ui || typeof appState.ui !== 'object' || Array.isArray(appState.ui)) {
131
+ appState.ui = {};
132
+ }
133
+ return appState.ui;
134
+ }
135
+
136
+ function densityThemePatch(density) {
137
+ if (density === 'compact') {
138
+ return {
139
+ gap: 8,
140
+ padding: 10,
141
+ fontSize: 15,
142
+ lineHeight: 18,
143
+ buttonHeight: 34,
144
+ };
145
+ }
146
+ return {
147
+ gap: 12,
148
+ padding: 14,
149
+ fontSize: 16,
150
+ lineHeight: 20,
151
+ buttonHeight: 40,
152
+ };
153
+ }
154
+
155
+ function contrastThemePatch(contrast) {
156
+ if (contrast === 'high') {
157
+ return {
158
+ textColor: '#ffffff',
159
+ mutedTextColor: '#d4e1ff',
160
+ panelBorderColor: '#ffffff',
161
+ buttonFocusColor: '#ffffff',
162
+ buttonTextColor: '#08111b',
163
+ };
164
+ }
165
+ return {};
166
+ }
167
+
168
+ export function listUiThemeChoices() {
169
+ return Object.values(UI_THEME_PRESETS).map((entry) => ({
170
+ id: entry.id,
171
+ label: entry.label,
172
+ description: entry.description,
173
+ }));
174
+ }
175
+
176
+ export function listUiDensityChoices() {
177
+ return UI_DENSITY_CHOICES.map((entry) => ({ ...entry }));
178
+ }
179
+
180
+ export function listUiContrastChoices() {
181
+ return UI_CONTRAST_CHOICES.map((entry) => ({ ...entry }));
182
+ }
183
+
184
+ export function createUiPreferencesSeed(overrides = {}) {
185
+ const source = overrides && typeof overrides === 'object' && !Array.isArray(overrides)
186
+ ? overrides
187
+ : {};
188
+ return {
189
+ schema: UI_PREFERENCES_SCHEMA,
190
+ themeId: normalizeChoice(source.themeId, listUiThemeChoices(), DEFAULT_UI_PREFERENCES.themeId),
191
+ density: normalizeChoice(source.density, UI_DENSITY_CHOICES, DEFAULT_UI_PREFERENCES.density),
192
+ contrast: normalizeChoice(source.contrast, UI_CONTRAST_CHOICES, DEFAULT_UI_PREFERENCES.contrast),
193
+ musicEnabled: normalizeBoolean(source.musicEnabled, DEFAULT_UI_PREFERENCES.musicEnabled),
194
+ subtitlesEnabled: normalizeBoolean(source.subtitlesEnabled, DEFAULT_UI_PREFERENCES.subtitlesEnabled),
195
+ masterVolume: Math.round(clampNumber(source.masterVolume, 0, 10, DEFAULT_UI_PREFERENCES.masterVolume)),
196
+ reduceMotion: normalizeBoolean(source.reduceMotion, DEFAULT_UI_PREFERENCES.reduceMotion),
197
+ };
198
+ }
199
+
200
+ export function normalizeUiPreferences(source = null) {
201
+ const merged = source && typeof source === 'object' && !Array.isArray(source)
202
+ ? { ...DEFAULT_UI_PREFERENCES, ...source }
203
+ : { ...DEFAULT_UI_PREFERENCES };
204
+ return createUiPreferencesSeed(merged);
205
+ }
206
+
207
+ export function ensureUiPreferences(appState, seed = null) {
208
+ const ui = ensureUiBucket(appState);
209
+ const fallback = createUiPreferencesSeed(seed || {});
210
+ if (!ui.preferences || typeof ui.preferences !== 'object' || Array.isArray(ui.preferences)) {
211
+ ui.preferences = fallback;
212
+ return ui.preferences;
213
+ }
214
+ ui.preferences = normalizeUiPreferences({
215
+ ...fallback,
216
+ ...ui.preferences,
217
+ });
218
+ return ui.preferences;
219
+ }
220
+
221
+ export function updateUiPreferences(appState, patch = {}) {
222
+ const current = ensureUiPreferences(appState);
223
+ const next = normalizeUiPreferences({
224
+ ...current,
225
+ ...(patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {}),
226
+ });
227
+ const changedKeys = Object.keys(next).filter((key) => JSON.stringify(current[key]) !== JSON.stringify(next[key]));
228
+ ensureUiBucket(appState).preferences = next;
229
+ return {
230
+ ok: true,
231
+ reasonCode: changedKeys.length > 0 ? 'ui_preferences_updated' : 'ui_preferences_unchanged',
232
+ changedKeys,
233
+ preferences: cloneUiValue(next),
234
+ };
235
+ }
236
+
237
+ export function resetUiPreferences(appState, seed = null) {
238
+ const next = createUiPreferencesSeed(seed || {});
239
+ ensureUiBucket(appState).preferences = next;
240
+ return {
241
+ ok: true,
242
+ reasonCode: 'ui_preferences_reset',
243
+ preferences: cloneUiValue(next),
244
+ };
245
+ }
246
+
247
+ export function resolveUiTheme(preferences = null) {
248
+ const normalized = normalizeUiPreferences(preferences);
249
+ const preset = UI_THEME_PRESETS[normalized.themeId] || UI_THEME_PRESETS[DEFAULT_UI_PREFERENCES.themeId];
250
+ const theme = {
251
+ ...cloneUiValue(preset.theme),
252
+ ...densityThemePatch(normalized.density),
253
+ ...contrastThemePatch(normalized.contrast),
254
+ };
255
+ return {
256
+ schema: UI_THEME_SCHEMA,
257
+ reasonCode: 'ui_theme_ready',
258
+ presetId: preset.id,
259
+ presetLabel: preset.label,
260
+ density: normalized.density,
261
+ contrast: normalized.contrast,
262
+ preferences: cloneUiValue(normalized),
263
+ theme,
264
+ availableThemeIds: Object.keys(UI_THEME_PRESETS),
265
+ };
266
+ }
267
+
268
+ export function inspectUiTheme(appState) {
269
+ return resolveUiTheme(ensureUiPreferences(appState));
270
+ }
271
+
272
+ export function applyUiTheme(appState, { preferences = null } = {}) {
273
+ const themeState = preferences
274
+ ? resolveUiTheme(preferences)
275
+ : inspectUiTheme(appState);
276
+ const runtime = globalThis.aura;
277
+ if (!runtime?.ui || typeof runtime.ui.setTheme !== 'function') {
278
+ return {
279
+ ok: false,
280
+ reasonCode: 'ui_theme_runtime_unavailable',
281
+ themeState,
282
+ };
283
+ }
284
+ const result = runtime.ui.setTheme(themeState.theme);
285
+ if (result?.ok === false) {
286
+ return {
287
+ ok: false,
288
+ reasonCode: result.reasonCode || 'ui_theme_apply_failed',
289
+ themeState,
290
+ };
291
+ }
292
+ return {
293
+ ok: true,
294
+ reasonCode: result?.reasonCode || 'ui_theme_updated',
295
+ themeState,
296
+ };
297
+ }
@@ -0,0 +1,14 @@
1
+ const hudScreen = {
2
+ id: 'hud',
3
+ kind: 'ui-screen',
4
+ anchors: [
5
+ { id: 'score', corner: 'top-left' },
6
+ { id: 'health', corner: 'top-right' },
7
+ ],
8
+ notes: [
9
+ 'Gameplay HUD drawing still routes through the gameplay scene.',
10
+ 'This authored screen keeps the example aligned with the scaffold folder contract.',
11
+ ],
12
+ };
13
+
14
+ export default hudScreen;