@cmpose/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  The cmpose generator library: it turns the pinned Compose Multiplatform template into a new project (validation, package and name rewrites, optional features and modules). See the README at the root of the cmpose repository for usage and development.
4
4
 
5
- It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame`.
5
+ It also generates Unity mobile games from the pinned game template: `game.validateGame`, `game.generateGame` and `game.describeGame` (`describeGame({ themes: await game.readThemes(templateDir) })` adds the template's theme families).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmpose/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "cmpose project generator core (Compose Multiplatform)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+ const { setScalar, setList } = require('./unityYaml');
5
+ const { readMetaGuid } = require('./guid');
6
+ const { hexToRgb, derivePalette } = require('./themeColors');
7
+
8
+ // cmpose game: writes a style family (themes.js) into the project as ThemeFamilies.Apply does in the editor: the light
9
+ // palette, the dark one while the uiTheme flag's folder is there, and the style asset (fonts, shapes, motion, post,
10
+ // world colours). Values are written the way Unity serializes them, so applying the template's own family changes nothing.
11
+ const LIGHT_PATH = 'Assets/_Project/Core/Presentation/Resources/Theme/Light.asset';
12
+ const DARK_PATH = 'Assets/_Project/Core/Theme/Resources/Theme/Dark.asset';
13
+ const STYLE_PATH = 'Assets/_Project/Core/Presentation/Resources/Theme/Style.asset';
14
+ const ASSET_DOC = { classId: 114, fileId: 11400000 };
15
+
16
+ // ThemeStyle's enums (SurfaceStyle, MotionProfile, BackgroundStyle): Unity stores the index
17
+ const ENUMS = {
18
+ surface: ['Flat', 'Soft', 'Raised'],
19
+ motion: ['Calm', 'Snappy', 'Bouncy'],
20
+ background: ['Solid', 'Gradient', 'Pattern']
21
+ };
22
+
23
+ // A float as Unity writes it: the shortest text that reads back as the same 32-bit float
24
+ function unityFloat(x) {
25
+ const f = Math.fround(x);
26
+ for (let p = 1; p <= 9; p++) {
27
+ const s = f.toPrecision(p);
28
+ if (Math.fround(Number(s)) === f) return String(Number(s));
29
+ }
30
+ return String(f);
31
+ }
32
+
33
+ function unityColor(hex) {
34
+ const { r, g, b, a } = hexToRgb(hex);
35
+ return `{r: ${unityFloat(r / 255)}, g: ${unityFloat(g / 255)}, b: ${unityFloat(b / 255)}, a: ${unityFloat(a / 255)}}`;
36
+ }
37
+
38
+ // Palette's serialized field of a ThemeRole: Background → background, OnPrimary → onPrimary
39
+ const fieldOf = (role) => role.charAt(0).toLowerCase() + role.slice(1);
40
+
41
+ // themes.json names assets by project path; only paths inside Assets are followed
42
+ function assetPath(projectDir, rel, what) {
43
+ if (typeof rel !== 'string' || !rel.startsWith('Assets/') || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
44
+ throw new Error(`theme: ${what} must be a path inside Assets`);
45
+ }
46
+ return path.join(projectDir, rel);
47
+ }
48
+
49
+ async function assetRef(projectDir, rel, what) {
50
+ const file = assetPath(projectDir, rel, what);
51
+ if (!(await fs.pathExists(file)) || !(await fs.pathExists(`${file}.meta`))) throw new Error(`theme: ${what} ${rel} is missing`);
52
+ return `{fileID: 11400000, guid: ${readMetaGuid(await fs.readFile(`${file}.meta`, 'utf8'))}, type: 2}`;
53
+ }
54
+
55
+ function enumIndex(name, value) {
56
+ const i = ENUMS[name].indexOf(value);
57
+ if (i < 0) throw new Error(`theme: style.${name} must be one of ${ENUMS[name].join(', ')}`);
58
+ return i;
59
+ }
60
+
61
+ function writePalette(text, roles) {
62
+ let out = text;
63
+ for (const [role, hex] of Object.entries(roles)) out = setScalar(out, fieldOf(role), unityColor(hex), { doc: ASSET_DOC });
64
+ return out;
65
+ }
66
+
67
+ async function readAsset(projectDir, rel) {
68
+ const file = path.join(projectDir, rel);
69
+ if (!(await fs.pathExists(file))) throw new Error(`theme: ${rel} is missing`);
70
+ return fs.readFile(file, 'utf8');
71
+ }
72
+
73
+ /** Writes themeValue (validateTheme().value: family, fontPair, primary?, accent?) of themes (readThemes) into
74
+ * projectDir. uiTheme: whether the flag is on (its Dark.asset is written, never created). Every asset is read and every
75
+ * reference resolved before anything is written. Returns the warnings of colours that fell back and the theme as
76
+ * applied (primary/accent only where they were kept). */
77
+ async function applyTheme(projectDir, themeValue, themes, { uiTheme = false } = {}) {
78
+ const family = themes.families.find((f) => f.id === themeValue.family);
79
+ if (!family) throw new Error('theme: unknown family');
80
+ const pair = family.fontPairs.find((p) => p.id === themeValue.fontPair);
81
+ if (!pair) throw new Error('theme: unknown font pair');
82
+ const style = family.style;
83
+
84
+ const requested = { primary: themeValue.primary, accent: themeValue.accent };
85
+ const light = derivePalette(family, requested, 'light');
86
+ const dark = uiTheme ? derivePalette(family, requested, 'dark') : null;
87
+
88
+ const display = await assetRef(projectDir, pair.display, 'display font');
89
+ const body = await assetRef(projectDir, pair.body, 'body font');
90
+ const post = await assetRef(projectDir, style.post, 'post profile');
91
+ const worldColors = Array.isArray(style.worldColors) ? style.worldColors : [];
92
+ if (worldColors.length < 3 || worldColors.length > 4) throw new Error('theme: style.worldColors needs 3 or 4 colours');
93
+ const numbers = {};
94
+ for (const [key, min, max] of [['cornerRadius', 0, 48], ['shadowStrength', 0, 1]]) {
95
+ const v = style[key];
96
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new Error(`theme: style.${key} must be from ${min} to ${max}`);
97
+ numbers[key] = unityFloat(v);
98
+ }
99
+ const enums = Object.fromEntries(Object.keys(ENUMS).map((k) => [k, enumIndex(k, style[k])]));
100
+
101
+ const lightText = writePalette(await readAsset(projectDir, LIGHT_PATH), light.roles);
102
+ const darkText = dark ? writePalette(await readAsset(projectDir, DARK_PATH), dark.roles) : null;
103
+ let styleText = await readAsset(projectDir, STYLE_PATH);
104
+ const scalars = [
105
+ ['familyId', family.id], ['displayFont', display], ['bodyFont', body],
106
+ ['cornerRadius', numbers.cornerRadius], ['surface', enums.surface], ['shadowStrength', numbers.shadowStrength],
107
+ ['motion', enums.motion], ['post', post], ['background', enums.background]
108
+ ];
109
+ for (const [key, v] of scalars) styleText = setScalar(styleText, key, v, { doc: ASSET_DOC });
110
+ styleText = setList(styleText, 'worldColors', worldColors.map(unityColor), { doc: ASSET_DOC });
111
+
112
+ await fs.writeFile(path.join(projectDir, LIGHT_PATH), lightText);
113
+ if (darkText !== null) await fs.writeFile(path.join(projectDir, DARK_PATH), darkText);
114
+ await fs.writeFile(path.join(projectDir, STYLE_PATH), styleText);
115
+
116
+ const warnings = [...light.warnings, ...(dark ? dark.warnings : [])];
117
+ const fellBack = (role) => warnings.some((w) => w.startsWith(`theme: ${role} `));
118
+ return {
119
+ warnings,
120
+ theme: {
121
+ family: family.id,
122
+ ...(themeValue.primary && !fellBack('Primary') ? { primary: themeValue.primary } : {}),
123
+ ...(themeValue.accent && !fellBack('Accent') ? { accent: themeValue.accent } : {}),
124
+ fontPair: pair.id
125
+ }
126
+ };
127
+ }
128
+
129
+ module.exports = { LIGHT_PATH, DARK_PATH, STYLE_PATH, unityFloat, unityColor, applyTheme };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
  const { hashContract } = require('../contract');
3
3
  const limits = require('../limits');
4
+ const { THEME_FIELDS } = require('./themes');
4
5
 
5
6
  // The Unity game generator's contract: what the game_* tools accept. Published facts feed contractVersion.
6
7
  const GAME_SCHEMA = Object.freeze({
@@ -26,7 +27,9 @@ const GAME_SCHEMA = Object.freeze({
26
27
  'button', 'lifetime', 'object', 'preserve', 'resources', 'serializable', 'transform',
27
28
  'about', 'ads', 'boot', 'common', 'guide', 'inspector', 'leaderboards', 'menu', 'network', 'pause',
28
29
  'purchases', 'runner2d', 'settings', 'theme', 'track3d', 'update'],
29
- reservedPackagePrefix: 'com.kturker'
30
+ reservedPackagePrefix: 'com.kturker',
31
+ // The theme request's fields; the families themselves come from the template (game_describe themes)
32
+ theme: { fields: THEME_FIELDS.slice(), colorFormat: '#RRGGBB' }
30
33
  });
31
34
 
32
35
  function gameContractFacts() {
@@ -2,8 +2,18 @@
2
2
  const pins = require('../pins');
3
3
  const { GAME_SCHEMA, gameContractFacts, gameContractVersion } = require('./contract');
4
4
 
5
- /** What the Unity game generator offers: engine, pinned template, defaults and the contract facts */
6
- function describeGame() {
5
+ // What a planner needs to pick a family: palettes, style and font paths stay in the template
6
+ function summarizeThemes(themes) {
7
+ const families = themes && Array.isArray(themes.families) ? themes.families : [];
8
+ return {
9
+ default: families.length ? themes.default : null,
10
+ families: families.map((f) => ({ id: f.id, title: f.title, suits: f.suits, fontPairs: f.fontPairs.map((p) => p.id) }))
11
+ };
12
+ }
13
+
14
+ /** What the Unity game generator offers: engine, pinned template, defaults, the template's theme families
15
+ * (readThemes of the pinned template; none when not given) and the contract facts */
16
+ function describeGame({ themes = null } = {}) {
7
17
  return {
8
18
  engine: GAME_SCHEMA.engine,
9
19
  unityVersion: GAME_SCHEMA.unityVersion,
@@ -17,8 +27,10 @@ function describeGame() {
17
27
  minSdk: GAME_SCHEMA.limits.minSdkMin,
18
28
  iosVersion: GAME_SCHEMA.limits.iosVersionMin
19
29
  },
30
+ // Outside `contract`: the families belong to the template, contractVersion hashes the contract only
31
+ themes: summarizeThemes(themes),
20
32
  contract: gameContractFacts()
21
33
  };
22
34
  }
23
35
 
24
- module.exports = { describeGame };
36
+ module.exports = { describeGame, summarizeThemes };
@@ -12,6 +12,8 @@ const { writeGameIds } = require('./ids');
12
12
  const { applyGameReadme } = require('./readme');
13
13
  const { buildGameRecord, writeGameRecord } = require('./record');
14
14
  const { verifyGameProject } = require('./verifier');
15
+ const { readThemes, validateTheme } = require('./themes');
16
+ const { applyTheme } = require('./applyTheme');
15
17
 
16
18
  // The template cache's own records and Unity's local state never reach the project; nor does the template's
17
19
  // LICENSE (the generated project is the user's, to license as they choose), nor an older template's GameCI
@@ -42,6 +44,11 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
42
44
  commit: await readMarker(COMMIT_FILE_NAME)
43
45
  };
44
46
 
47
+ // The family and font pair are checked against the template's themes.json before anything changes
48
+ const themes = await readThemes(projectDir);
49
+ const theme = validateTheme(value.theme, themes);
50
+ if (!theme.ok) throw new Error(theme.error);
51
+
45
52
  // 1. leftovers
46
53
  for (const leftover of LEFTOVERS) await fs.remove(path.join(projectDir, leftover));
47
54
  log.info('✅ Cleanup completed');
@@ -63,13 +70,20 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
63
70
  await applyGameReadme(projectDir, value);
64
71
  log.info(`✅ Renamed to ${value.companyName}.${value.projectName} (${value.packageName})`);
65
72
 
66
- // 7. a broken project is never returned
73
+ // 7. the style family (the template's default when none was asked; nothing for a template without families)
74
+ let applied = { warnings: [], theme: null };
75
+ if (theme.value) {
76
+ applied = await applyTheme(projectDir, theme.value, themes, { uiTheme: value.flags.includes('uiTheme') });
77
+ log.info(`✅ Applied theme ${applied.theme.family} (${applied.theme.fontPair})`);
78
+ }
79
+
80
+ // 8. a broken project is never returned
67
81
  const problems = await verifyGameProject(projectDir);
68
82
  if (problems.length) throw new Error('generated project failed verification:\n- ' + problems.join('\n- '));
69
83
 
70
- // 8. the template's package guid allowlist goes; the record stays
84
+ // 9. the template's package guid allowlist goes; the record stays
71
85
  await fs.remove(path.join(projectDir, PACKAGE_GUIDS));
72
- const record = buildGameRecord({ template: source, coreVersion, generatorVersion, value, now });
86
+ const record = buildGameRecord({ template: source, coreVersion, generatorVersion, value, theme: applied.theme, now });
73
87
  await writeGameRecord(projectDir, record);
74
88
 
75
89
  return {
@@ -78,7 +92,8 @@ async function generateGame(value, { projectDir, template = null, coreVersion, g
78
92
  flags: value.flags.slice(),
79
93
  features: value.features.slice(),
80
94
  ids: record.ids,
81
- warnings: gameWarnings(value)
95
+ theme: applied.theme,
96
+ warnings: [...applied.warnings, ...gameWarnings(value)]
82
97
  };
83
98
  }
84
99
 
package/src/game/index.js CHANGED
@@ -9,6 +9,9 @@ const { writeGameIds } = require('./ids');
9
9
  const { applyGameReadme } = require('./readme');
10
10
  const { buildGameRecord, writeGameRecord } = require('./record');
11
11
  const { verifyGameProject } = require('./verifier');
12
+ const { readThemes, validateTheme, ThemesFileError } = require('./themes');
13
+ const { applyTheme } = require('./applyTheme');
14
+ const { derivePalette } = require('./themeColors');
12
15
 
13
16
  module.exports = {
14
17
  validateGame,
@@ -24,6 +27,11 @@ module.exports = {
24
27
  buildGameRecord,
25
28
  writeGameRecord,
26
29
  verifyGameProject,
30
+ readThemes,
31
+ ThemesFileError,
32
+ validateTheme,
33
+ applyTheme,
34
+ derivePalette,
27
35
  flags: require('./flags'),
28
36
  guid: require('./guid'),
29
37
  guidIntegrity: require('./guidIntegrity'),
@@ -20,8 +20,9 @@ function idStatuses(flags, ids) {
20
20
  const strings = (list) => (Array.isArray(list) ? list.filter((x) => typeof x === 'string') : []);
21
21
 
22
22
  /** The .cmpose/generation.json content of a generated Unity game (no personal data, no ID values);
23
- * generatorVersion names the caller (e.g. mcp@0.2.0), null when not given */
24
- function buildGameRecord({ template, coreVersion, generatorVersion = null, value, now = new Date() }) {
23
+ * generatorVersion names the caller (e.g. mcp@0.2.0), null when not given; theme the style family as applied
24
+ * (applyTheme), null for a template without families */
25
+ function buildGameRecord({ template, coreVersion, generatorVersion = null, value, theme = null, now = new Date() }) {
25
26
  const flags = strings(value.flags);
26
27
  return {
27
28
  generator: '@cmpose/core',
@@ -36,6 +37,7 @@ function buildGameRecord({ template, coreVersion, generatorVersion = null, value
36
37
  minSdk: value.minSdk,
37
38
  iosVersion: value.iosVersion,
38
39
  ids: idStatuses(flags, value.ids),
40
+ theme: theme ? { ...theme } : null,
39
41
  template: { repo: template.repo, ref: template.ref, commit: template.commit },
40
42
  generatedAt: now.toISOString()
41
43
  };
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ // cmpose game: a family's palette with the requested Primary/Accent. The colours keep the requested hue and saturation;
4
+ // only the lightness moves, until every contrast floor the template's own tests ask for holds (ThemeFamilyTests'
5
+ // AssertReads, Contrast.AssertBoxes and AssertAccentAndStatus). A colour that cannot reach them stays the family's.
6
+
7
+ // Contrast floors of Contrast (Assets/_Project/Tests/EditMode/ThemeTests.cs)
8
+ const BODY_TEXT = 4.5;
9
+ const NON_TEXT = 3;
10
+ const SMALL_TEXT = 3;
11
+ const BOX_ON_PANEL = 1.25;
12
+ const SELECTED_ON_ROW = 1.1;
13
+ // The floors that differ by palette: Primary text on the rows, Control on Surface
14
+ const MODE_FLOORS = {
15
+ light: { accentOnRow: 3, controlOnSurface: 1.4 },
16
+ dark: { accentOnRow: 2.3, controlOnSurface: 3 }
17
+ };
18
+ // A requested hue stays visible: never shifted to black or white
19
+ const MIN_LIGHTNESS = 0.1;
20
+ const MAX_LIGHTNESS = 0.9;
21
+ const STEP = 0.01;
22
+
23
+ function hexToRgb(hex) {
24
+ const m = /^#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})?$/.exec(hex);
25
+ if (!m) throw new Error(`'${hex}' is not a #RRGGBB colour`);
26
+ return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: m[4] === undefined ? 255 : parseInt(m[4], 16) };
27
+ }
28
+
29
+ const byte = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0').toUpperCase();
30
+ const rgbToHex = ({ r, g, b }) => `#${byte(r)}${byte(g)}${byte(b)}`;
31
+
32
+ // WCAG 2 relative luminance, as Contrast.Luminance computes it
33
+ function luminance(hex) {
34
+ const { r, g, b } = hexToRgb(hex);
35
+ const channel = (c) => {
36
+ const v = c / 255;
37
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
38
+ };
39
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
40
+ }
41
+
42
+ function contrast(a, b) {
43
+ const la = luminance(a);
44
+ const lb = luminance(b);
45
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
46
+ }
47
+
48
+ function hexToHsl(hex) {
49
+ const { r, g, b } = hexToRgb(hex);
50
+ const [R, G, B] = [r / 255, g / 255, b / 255];
51
+ const max = Math.max(R, G, B);
52
+ const min = Math.min(R, G, B);
53
+ const l = (max + min) / 2;
54
+ if (max === min) return { h: 0, s: 0, l };
55
+ const d = max - min;
56
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
57
+ let h;
58
+ if (max === R) h = (G - B) / d + (G < B ? 6 : 0);
59
+ else if (max === G) h = (B - R) / d + 2;
60
+ else h = (R - G) / d + 4;
61
+ return { h: h / 6, s, l };
62
+ }
63
+
64
+ function hslToHex({ h, s, l }) {
65
+ if (s === 0) return rgbToHex({ r: l * 255, g: l * 255, b: l * 255 });
66
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
67
+ const p = 2 * l - q;
68
+ const hue = (t) => {
69
+ if (t < 0) t += 1;
70
+ if (t > 1) t -= 1;
71
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
72
+ if (t < 1 / 2) return q;
73
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
74
+ return p;
75
+ };
76
+ return rgbToHex({ r: hue(h + 1 / 3) * 255, g: hue(h) * 255, b: hue(h - 1 / 3) * 255 });
77
+ }
78
+
79
+ // Every floor a palette must meet: [a, b, floor] contrast pairs, then the colours that must differ
80
+ function contrastPairs(mode) {
81
+ const { accentOnRow, controlOnSurface } = MODE_FLOORS[mode];
82
+ return [
83
+ ['Text', 'Background', BODY_TEXT],
84
+ ['Text', 'Surface', BODY_TEXT],
85
+ ['TextMuted', 'Surface', BODY_TEXT],
86
+ ['OnPrimary', 'Primary', BODY_TEXT],
87
+ ['OnAccent', 'Accent', BODY_TEXT],
88
+ ['Danger', 'Surface', NON_TEXT],
89
+ ['Success', 'Surface', NON_TEXT],
90
+ ['Primary', 'Surface', NON_TEXT],
91
+ ['Primary', 'Control', NON_TEXT],
92
+ ['Control', 'Surface', controlOnSurface],
93
+ ['Track', 'Surface', BOX_ON_PANEL],
94
+ ['SurfaceVariant', 'Surface', BOX_ON_PANEL],
95
+ ['SurfaceVariant', 'Background', BOX_ON_PANEL],
96
+ ['SurfaceVariant', 'Track', SELECTED_ON_ROW],
97
+ ['TextMuted', 'Track', SMALL_TEXT],
98
+ ['TextMuted', 'SurfaceVariant', SMALL_TEXT],
99
+ ['Primary', 'Track', accentOnRow],
100
+ ['Primary', 'SurfaceVariant', accentOnRow],
101
+ ['Primary', 'Track', NON_TEXT]
102
+ ];
103
+ }
104
+ const DISTINCT = [['Accent', 'Primary'], ['Success', 'Danger']];
105
+
106
+ const sameColour = (a, b) => a.toUpperCase() === b.toUpperCase();
107
+
108
+ /** The floors roles (role → #RRGGBB) misses in mode ('light' | 'dark'), as 'A on B' / 'A is B'; only the pairs
109
+ * that involve one of `only` when given */
110
+ function paletteProblems(roles, mode, only = null) {
111
+ if (!MODE_FLOORS[mode]) throw new Error(`unknown palette mode '${mode}'`);
112
+ const concerns = (a, b) => !only || only.includes(a) || only.includes(b);
113
+ const problems = [];
114
+ for (const [a, b, floor] of contrastPairs(mode)) {
115
+ if (concerns(a, b) && contrast(roles[a], roles[b]) < floor) problems.push(`${a} on ${b}`);
116
+ }
117
+ for (const [a, b] of DISTINCT) if (concerns(a, b) && sameColour(roles[a], roles[b])) problems.push(`${a} is ${b}`);
118
+ return problems;
119
+ }
120
+
121
+ // Lightness values from the requested one outwards, inside the visible range
122
+ function lightnessSteps(from) {
123
+ const start = Math.min(MAX_LIGHTNESS, Math.max(MIN_LIGHTNESS, from));
124
+ const steps = [start];
125
+ for (let d = STEP; d <= 1; d += STEP) {
126
+ if (start - d >= MIN_LIGHTNESS - 1e-9) steps.push(start - d);
127
+ if (start + d <= MAX_LIGHTNESS + 1e-9) steps.push(start + d);
128
+ }
129
+ return steps;
130
+ }
131
+
132
+ const unique = (list) => list.filter((c, i) => list.findIndex((x) => sameColour(x, c)) === i);
133
+
134
+ // The requested colour itself when it passes, else its hue at the nearest lightness that does; the text on it is the
135
+ // family's On colour, its Text or its Surface (whichever reads); Control moves only when nothing else works
136
+ function shiftRole(roles, mode, role, requested) {
137
+ const on = `On${role}`;
138
+ const changes = role === 'Primary' ? [role, on, 'Control'] : [role, on];
139
+ const ons = unique([roles[on], roles.Text, roles.Surface]);
140
+ const hsl = hexToHsl(requested);
141
+ const candidates = unique([requested.toUpperCase(), ...lightnessSteps(hsl.l).map((l) => hslToHex({ ...hsl, l }))]);
142
+ const controls = [roles.Control];
143
+ if (role === 'Primary') {
144
+ const c = hexToHsl(roles.Control);
145
+ controls.push(...unique(lightnessSteps(c.l).map((l) => hslToHex({ ...c, l }))).filter((x) => !sameColour(x, roles.Control)));
146
+ }
147
+ for (const control of controls) {
148
+ for (const colour of candidates) {
149
+ for (const text of ons) {
150
+ const next = { ...roles, [role]: colour, [on]: text, ...(role === 'Primary' ? { Control: control } : {}) };
151
+ if (!paletteProblems(next, mode, changes).length) return next;
152
+ }
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /** family's palette for mode ('light' | 'dark') with the requested primary/accent (#RRGGBB) shifted to pass every
159
+ * contrast floor. roles: role → #RRGGBB(AA); warnings: one line per colour that fell back to the family's */
160
+ function derivePalette(family, { primary, accent } = {}, mode = 'light') {
161
+ if (!family || !family[mode]) throw new Error(`family has no ${mode} palette`);
162
+ let roles = { ...family[mode] };
163
+ const warnings = [];
164
+ for (const [role, requested] of [['Primary', primary], ['Accent', accent]]) {
165
+ if (!requested) continue;
166
+ const next = shiftRole(roles, mode, role, requested);
167
+ if (next) roles = next;
168
+ else warnings.push(`theme: ${role} fell back to the family default in the ${mode} palette`);
169
+ }
170
+ return { roles, warnings };
171
+ }
172
+
173
+ module.exports = { MODE_FLOORS, hexToRgb, rgbToHex, luminance, contrast, hexToHsl, hslToHex, paletteProblems, derivePalette };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const fs = require('fs-extra');
4
+
5
+ // The template owns its style families (palettes, style, font pairs); older pins have no such file
6
+ const THEMES_PATH = path.join('.cmpose', 'themes.json');
7
+ const THEME_FIELDS = ['family', 'primary', 'accent', 'fontPair'];
8
+ const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
9
+ // Palette entries: #RRGGBB or #RRGGBBAA (Scrim carries alpha)
10
+ const PALETTE_HEX_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
11
+
12
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
13
+ const isText = (v) => typeof v === 'string' && v.length > 0;
14
+
15
+ // A template's themes.json that is there but cannot be used
16
+ class ThemesFileError extends Error { }
17
+
18
+ function broken(why) {
19
+ return new ThemesFileError(`${THEMES_PATH.split(path.sep).join('/')} is not valid: ${why}`);
20
+ }
21
+
22
+ function checkPalette(id, name, palette) {
23
+ if (!isObject(palette)) throw broken(`family "${id}" has no ${name} palette`);
24
+ for (const [key, value] of Object.entries(palette)) {
25
+ if (typeof value !== 'string' || !PALETTE_HEX_RE.test(value)) throw broken(`family "${id}" ${name}.${key} is not a #RRGGBB colour`);
26
+ }
27
+ }
28
+
29
+ function checkFamily(f) {
30
+ if (!isObject(f) || !isText(f.id)) throw broken('a family has no id');
31
+ checkPalette(f.id, 'light', f.light);
32
+ checkPalette(f.id, 'dark', f.dark);
33
+ if (!isObject(f.style)) throw broken(`family "${f.id}" has no style`);
34
+ if (!Array.isArray(f.fontPairs) || !f.fontPairs.length) throw broken(`family "${f.id}" has no font pairs`);
35
+ for (const p of f.fontPairs) {
36
+ if (!isObject(p) || !isText(p.id) || !isText(p.display) || !isText(p.body)) throw broken(`family "${f.id}" has a font pair without id, display or body`);
37
+ }
38
+ return {
39
+ id: f.id, title: f.title, suits: f.suits,
40
+ light: { ...f.light }, dark: { ...f.dark }, style: { ...f.style },
41
+ fontPairs: f.fontPairs.map((p) => ({ id: p.id, display: p.display, body: p.body }))
42
+ };
43
+ }
44
+
45
+ /** The template's style families from .cmpose/themes.json; no families when the file is missing.
46
+ * A file that is there but broken throws: the generator must not guess a theme. */
47
+ async function readThemes(templateDir) {
48
+ const file = path.join(templateDir, THEMES_PATH);
49
+ if (!(await fs.pathExists(file))) return { version: null, default: null, families: [] };
50
+ let data;
51
+ try {
52
+ data = JSON.parse(await fs.readFile(file, 'utf8'));
53
+ } catch {
54
+ throw broken('not JSON');
55
+ }
56
+ if (!isObject(data) || !Array.isArray(data.families)) throw broken('no families list');
57
+ const families = data.families.map(checkFamily);
58
+ const ids = families.map((f) => f.id);
59
+ if (new Set(ids).size !== ids.length) throw broken('duplicate family ids');
60
+ if (!ids.includes(data.default)) throw broken(`default "${data.default}" is not a family`);
61
+ return { version: data.version, default: data.default, families };
62
+ }
63
+
64
+ /** theme's shape alone (validateGame has no template): an object of known string fields, colours as #RRGGBB.
65
+ * value keeps the given fields, colours uppercased. */
66
+ function checkThemeShape(theme) {
67
+ if (!isObject(theme)) return { error: 'theme must be an object' };
68
+ const extra = Object.keys(theme).filter((k) => !THEME_FIELDS.includes(k));
69
+ if (extra.length) return { error: `theme has unknown fields: ${extra.join(', ')} (known: ${THEME_FIELDS.join(', ')})` };
70
+ const value = {};
71
+ for (const k of THEME_FIELDS) {
72
+ if (!Object.hasOwn(theme, k)) continue;
73
+ const v = theme[k];
74
+ if (typeof v !== 'string') return { error: `theme.${k} must be a string` };
75
+ if ((k === 'primary' || k === 'accent') && !HEX_RE.test(v)) return { error: `theme.${k} must look like #RRGGBB` };
76
+ value[k] = k === 'primary' || k === 'accent' ? v.toUpperCase() : v;
77
+ }
78
+ return { value };
79
+ }
80
+
81
+ // Errors list the valid ids but never echo the request's value back
82
+ /** A theme request against the template's families (readThemes). value: { family, primary?, accent?, fontPair },
83
+ * the default family and each family's first font pair filling gaps; null when the template has no families and
84
+ * none was asked for. */
85
+ function validateTheme(theme, themes) {
86
+ const families = themes && Array.isArray(themes.families) ? themes.families : [];
87
+ const asked = theme !== undefined && theme !== null;
88
+ if (!families.length) {
89
+ return asked ? { ok: false, error: 'theme cannot be used: this template has no theme families' } : { ok: true, value: null };
90
+ }
91
+ const shape = asked ? checkThemeShape(theme) : { value: {} };
92
+ if (shape.error) return { ok: false, error: shape.error };
93
+ const t = shape.value;
94
+ const ids = families.map((f) => f.id);
95
+ const familyId = t.family === undefined ? themes.default : t.family;
96
+ const family = families.find((f) => f.id === familyId);
97
+ if (!family) return { ok: false, error: `theme.family must be one of this template's families: ${ids.join(', ')}` };
98
+ const pairs = family.fontPairs.map((p) => p.id);
99
+ const fontPair = t.fontPair === undefined ? pairs[0] : t.fontPair;
100
+ if (!pairs.includes(fontPair)) return { ok: false, error: `theme.fontPair must be one of ${family.id}'s font pairs: ${pairs.join(', ')}` };
101
+ return {
102
+ ok: true,
103
+ value: {
104
+ family: family.id,
105
+ ...(t.primary ? { primary: t.primary } : {}),
106
+ ...(t.accent ? { accent: t.accent } : {}),
107
+ fontPair
108
+ }
109
+ };
110
+ }
111
+
112
+ module.exports = { THEMES_PATH, THEME_FIELDS, ThemesFileError, readThemes, validateTheme, checkThemeShape };
@@ -2,6 +2,7 @@
2
2
  const PV = require('../ProjectValidator');
3
3
  const limits = require('../limits');
4
4
  const { GAME_SCHEMA: S } = require('./contract');
5
+ const { checkThemeShape } = require('./themes');
5
6
 
6
7
  const CSHARP_KEYWORDS = ('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while').split(' ');
7
8
  const KOTLIN_KEYWORDS = PV.KOTLIN_HARD_KEYWORDS;
@@ -179,9 +180,17 @@ function validateGame(input) {
179
180
  ids = r.value;
180
181
  }
181
182
 
183
+ // The family and font pair are checked against the template's themes.json later (validateTheme)
184
+ let theme = null;
185
+ if (input.theme !== undefined) {
186
+ const r = checkThemeShape(input.theme);
187
+ if (r.error) return fail(r.error);
188
+ theme = r.value;
189
+ }
190
+
182
191
  return {
183
192
  ok: true,
184
- value: { projectName, appName, packageName, companyName, templateType, render, flags, features, minSdk, iosVersion, ids }
193
+ value: { projectName, appName, packageName, companyName, templateType, render, flags, features, minSdk, iosVersion, ids, theme }
185
194
  };
186
195
  }
187
196
 
package/src/pins.js CHANGED
@@ -3,7 +3,7 @@
3
3
  module.exports = Object.freeze({
4
4
  cmp: Object.freeze({ repo: 'whinstudio/cmpose-template', ref: 'template-2026.10.17' }),
5
5
  // The release step replaces the placeholder with the game template's tag
6
- game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.15.2' }),
6
+ game: Object.freeze({ repo: 'whinstudio/cmpose-game-template', ref: 'game-template-2026.10.17' }),
7
7
  // The release step replaces the placeholder with the backend template's tag
8
8
  backend: Object.freeze({ repo: 'whinstudio/cmpose-backend-template', ref: 'backend-template-2026.10.17' })
9
9
  });