Repository navigation
Expand file tree
/
Copy pathapp-registry.js
More file actions
185 lines (167 loc) · 8.43 KB
/
Copy pathapp-registry.js
File metadata and controls
185 lines (167 loc) · 8.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
// Shared accent registry for the unified app theme.
// Single source of truth for which hue/chroma each app owns, plus a helper
// that suggests the next open slot so a new project doesn't have to be
// eyeballed against the existing set.
//
// The accent formula is the same for every app, and accentCssFor() emits it:
//
// light --accent oklch(0.54 C H) --accent-soft oklch(0.95 0.03 H)
// --accent-text oklch(0.42 C H) --on-accent #ffffff
// dark --accent oklch(0.74 max(0.85C, 0.09) H) --accent-soft oklch(0.32 0.07 H)
// --accent-text oklch(0.85 0.1 H) --on-accent #211f1c
//
// Light lightness is 0.54, not the 0.60 it used to be: at 0.60 neither a white
// nor a dark label reached 4.5:1 on a primary button for most apps. At 0.54
// white clears it for every app below. Not for every possible hue, though:
// greens and cyans (roughly H156-226) with chroma above 0.13 land too light once
// they're fitted into sRGB. `npm run check` fails for any row whose label
// misses 4.5:1 in either theme; lower that app's chroma to fix it.
// dataLast: the data palette slot (1-6) closest to the app's accent. The app
// uses it last, so its first data series never reads as its accent.
// `npm run check` recomputes it and fails when it's stale.
export const APPS = [
{ key: 'oneofus', name: 'OneOfUs', hue: 70, chroma: 0.17, dataLast: 5, note: 'Real brand color #d97706' },
{ key: 'idleairport', name: 'IdleAirport', hue: 75, chroma: 0.14, dataLast: 5, note: 'Real brand color #ffd24a' },
{ key: 'wescup', name: 'Wes Cup', hue: 95, chroma: 0.15, dataLast: 1 },
{ key: 'parametricchaos',name: 'ParametricChaos', hue: 155, chroma: 0.13, dataLast: 3 },
{ key: 'fivebucks', name: "That'll Be 5 Bucks", hue: 185, chroma: 0.13, dataLast: 3 },
{ key: 'washmycar', name: 'WashMyCar', hue: 230, chroma: 0.13, dataLast: 3 },
{ key: 'idrovewhere', name: 'iDroveWhere', hue: 256, chroma: 0.15, dataLast: 4 },
{ key: 'modeler', name: 'Modeler', hue: 300, chroma: 0.16, dataLast: 6 },
{ key: 'scoreacardgame', name: 'ScoreACardGame', hue: 20, chroma: 0.16, dataLast: 5 },
{ key: 'sessionlog', name: 'Session Log', hue: 340, chroma: 0.15, dataLast: 2 },
{ key: 'infraconsole', name: 'Infra Console', hue: 125, chroma: 0.13, dataLast: 1 },
];
// Keys that were renamed, and the key each one now points at. The old key keeps
// working everywhere a key is accepted, so an app can move to the new one when
// it next upgrades. Removed at the next major release.
export const ALIASES = {
drinkwin: 'wescup',
whatwillithink: 'modeler',
};
/** The registry row for an app key, following a renamed key to its new one.
* Throws for a key that isn't in the registry. */
export function appFor(key) {
const row = APPS.find((a) => a.key === (ALIASES[key] ?? key));
if (!row) throw new Error(`No app "${key}" in the registry. Keys: ${APPS.map((a) => a.key).join(", ")}`);
return row;
}
// The data palette has six slots (--data-1 … --data-6, tokens/colors.css).
export const DATA_SLOTS = 6;
// Minimum hue separation (degrees) before two accents are considered "too
// close" for comfort if the apps might ever appear side by side.
export const MIN_HUE_GAP = 25;
/** Sorted (hue asc) copy of APPS, for wheel layout / gap math. */
export function sortedByHue() {
return [...APPS].sort((a, b) => a.hue - b.hue);
}
/** Returns each adjacent gap around the wheel (wrapping 360 -> 0), sorted by
* size descending: [{ from, to, start, end, mid, size }]. */
export function hueGaps() {
const sorted = sortedByHue();
const gaps = [];
for (let i = 0; i < sorted.length; i++) {
const a = sorted[i];
const b = sorted[(i + 1) % sorted.length];
const start = a.hue;
const end = i === sorted.length - 1 ? b.hue + 360 : b.hue;
const size = end - start;
gaps.push({ from: a.name, to: b.name, start, end: end % 360, mid: (start + size / 2) % 360, size });
}
return gaps.sort((x, y) => y.size - x.size);
}
/** Suggests up to `count` open hues for new apps: the widest gaps' midpoints,
* skipped if the gap is too tight to safely place anything in (< 2x MIN_HUE_GAP). */
export function suggestOpenSlots(count = 4) {
return hueGaps()
.filter((g) => g.size >= MIN_HUE_GAP * 2)
.slice(0, count)
.map((g) => ({ hue: Math.round(g.mid), betweenAfter: g.from, betweenBefore: g.to, gap: g.size }));
}
/** Flags any pair of apps whose hues sit closer than MIN_HUE_GAP. */
export function crowdedPairs() {
const gaps = hueGaps();
return gaps.filter((g) => g.size < MIN_HUE_GAP);
}
const LIGHT_L = 0.54;
const DARK_L = 0.74;
// High contrast (prefers-contrast: more): the accent moves just far enough
// that, for every app, the primary button's label reaches 7:1 on the fill and
// accent text reaches 7:1 on the page, the surface and the soft fill. Solved
// across all the apps below with a little margin; npm run check holds them to
// it. Dark accent text (0.85) already clears 7:1, so it doesn't move.
const CONTRAST_LIGHT_L = 0.42;
const CONTRAST_LIGHT_TEXT_L = 0.39;
const CONTRAST_DARK_L = 0.76;
// The label and glyph colour on a solid accent. The dark theme lightens every
// accent, so white can't reach 4.5:1 on any of them there; a dark label can.
const ON_ACCENT = { light: "#ffffff", dark: "#211f1c" };
// Four decimals: 0.14 * 0.85 is 0.11900000000000001 in floating point, and
// that string would otherwise end up in someone's stylesheet.
const round4 = (n) => Math.round(n * 1e4) / 1e4;
function resolveApp(app, chroma) {
if (typeof app !== "string") return { hue: app, chroma };
return appFor(app);
}
/** The solid accent colour for a hue and chroma, light or dark. */
export function accentFor(hue, chroma, dark = false) {
return dark
? `oklch(${DARK_L} ${round4(Math.max(chroma * 0.85, 0.09))} ${hue})`
: `oklch(${LIGHT_L} ${round4(chroma)} ${hue})`;
}
/** Every accent token for one app, light and dark: the three an app overrides
* per theme, plus the label colour that sits on the accent. Pass an app key
* from APPS (`accentTokensFor("washmycar")`), or a hue and a chroma. */
export function accentTokensFor(app, chroma) {
const { hue, chroma: c } = resolveApp(app, chroma);
return {
light: {
"--accent": accentFor(hue, c),
"--accent-soft": `oklch(0.95 0.03 ${hue})`,
"--accent-text": `oklch(0.42 ${round4(c)} ${hue})`,
"--on-accent": ON_ACCENT.light,
},
dark: {
"--accent": accentFor(hue, c, true),
"--accent-soft": `oklch(0.32 0.07 ${hue})`,
"--accent-text": `oklch(0.85 0.1 ${hue})`,
"--on-accent": ON_ACCENT.dark,
},
// Only what high contrast changes; everything else stays as above.
contrast: {
light: {
"--accent": `oklch(${CONTRAST_LIGHT_L} ${round4(c)} ${hue})`,
"--accent-text": `oklch(${CONTRAST_LIGHT_TEXT_L} ${round4(c)} ${hue})`,
},
// --accent-text doesn't change in dark, but it's restated: the light
// block above is just as specific and comes first, so without this its
// light value would win in dark mode.
dark: {
"--accent": `oklch(${CONTRAST_DARK_L} ${round4(Math.max(c * 0.85, 0.09))} ${hue})`,
"--accent-text": `oklch(0.85 0.1 ${hue})`,
},
},
};
}
/** The order an app assigns its data colours in: slots 1-6, with the slot
* closest to its accent moved to the end. `dataOrderFor("oneofus")` gives
* [1, 2, 3, 4, 6, 5]: the first series gets --data-1, and so on. */
export function dataOrderFor(app) {
const row = appFor(app);
const slots = Array.from({ length: DATA_SLOTS }, (_, i) => i + 1);
return [...slots.filter((n) => n !== row.dataLast), row.dataLast];
}
/** The same tokens as the stylesheet block an app pastes into its root CSS,
* high contrast included: an app's own accent block comes after the
* package's, so without its own high-contrast part it would override the
* package's and the accent would stay at normal contrast. */
export function accentCssFor(app, chroma) {
const { light, dark, contrast } = accentTokensFor(app, chroma);
const block = (selector, vars, pad = "") =>
`${pad}${selector} {\n${Object.entries(vars).map(([k, v]) => `${pad} ${k}: ${v};`).join("\n")}\n${pad}}`;
return [
block(":root", light),
block('[data-theme="dark"]', dark),
`@media (prefers-contrast: more) {\n${block(":root", contrast.light, " ")}\n${block('[data-theme="dark"]', contrast.dark, " ")}\n}`,
].join("\n") + "\n";
}