@rafters/color-utils 0.2.3
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/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +44 -0
- package/src/accessibility.ts +277 -0
- package/src/analysis.ts +166 -0
- package/src/apca-w3.d.ts +28 -0
- package/src/builder.ts +164 -0
- package/src/color-wheel.ts +235 -0
- package/src/colorjs.d.ts +36 -0
- package/src/conversion.ts +92 -0
- package/src/gamut.ts +117 -0
- package/src/harmony.ts +194 -0
- package/src/index.ts +110 -0
- package/src/internal/math.ts +35 -0
- package/src/manipulation.ts +16 -0
- package/src/naming/generator.ts +161 -0
- package/src/naming/hue-hubs.ts +2452 -0
- package/src/naming/index.ts +50 -0
- package/src/naming/quantize.ts +85 -0
- package/src/naming/word-banks.ts +154 -0
- package/src/scale-positions.ts +31 -0
- package/src/semantic.ts +397 -0
package/src/semantic.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic selection — purpose-driven pair finding over a ColorValue (#1636).
|
|
3
|
+
*
|
|
4
|
+
* One closure captures a family's scale + accessibility data; purpose
|
|
5
|
+
* functions parameterize the same walk: pick a position for a use, return
|
|
6
|
+
* the pair with its provenance visible.
|
|
7
|
+
*
|
|
8
|
+
* Contract (Sean, 2026-06-09):
|
|
9
|
+
* - Family-agnostic: these functions search whatever ColorValue they are
|
|
10
|
+
* handed. The caller chooses which family serves the purpose. No special
|
|
11
|
+
* neutral handling.
|
|
12
|
+
* - Pairs are found in LIGHT mode, then inverted AS A UNIT for dark mode —
|
|
13
|
+
* the relationship survives instead of each leg re-deriving independently.
|
|
14
|
+
* - When an inverted pair fails contrast: nudge to the nearest passing pair.
|
|
15
|
+
* Nudging corrects validity; designer override corrects taste.
|
|
16
|
+
* - Every result reports `tier` so degradation is visible downstream.
|
|
17
|
+
*
|
|
18
|
+
* Foreground and state selection are behavior-identical to the design-tokens
|
|
19
|
+
* contrast/state plugins (the parity baseline). The dark inversion replaces
|
|
20
|
+
* findDarkCounterpartIndex's max-distance walk — the #1635 fix.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ColorValue,
|
|
25
|
+
OKLCH,
|
|
26
|
+
SemanticColorSuggestions,
|
|
27
|
+
SemanticStatusRole,
|
|
28
|
+
} from '@rafters/shared';
|
|
29
|
+
import { roundOKLCH } from './conversion.js';
|
|
30
|
+
import { clampColor } from './gamut.js';
|
|
31
|
+
import { clamp01, normalizeHue } from './internal/math.js';
|
|
32
|
+
import { POSITION_TO_INDEX, SCALE_POSITIONS } from './scale-positions.js';
|
|
33
|
+
|
|
34
|
+
export type StateUse = 'hover' | 'active' | 'focus' | 'disabled' | 'border' | 'ring' | 'subtle';
|
|
35
|
+
export type PairUse = 'foreground' | StateUse;
|
|
36
|
+
|
|
37
|
+
/** Which strategy produced the pair. Ordered roughly best -> worst. */
|
|
38
|
+
export type PairTier = 'reference' | 'pair-exact' | 'pair-nearest' | 'ladder' | 'inversion';
|
|
39
|
+
|
|
40
|
+
export type PairStandard = 'AAA' | 'AA' | 'none';
|
|
41
|
+
|
|
42
|
+
export interface PairLeg {
|
|
43
|
+
family: string;
|
|
44
|
+
position: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Pair {
|
|
48
|
+
from: PairLeg;
|
|
49
|
+
to: PairLeg;
|
|
50
|
+
standard: PairStandard;
|
|
51
|
+
tier: PairTier;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PairRequest {
|
|
55
|
+
use: PairUse;
|
|
56
|
+
from: string;
|
|
57
|
+
level?: 'AA' | 'AAA';
|
|
58
|
+
dark?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SemanticContext {
|
|
62
|
+
pair(request: PairRequest): Pair;
|
|
63
|
+
states(from: string, dark?: boolean): Record<StateUse, Pair>;
|
|
64
|
+
invert(pair: Pair): Pair;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class SemanticSelectionError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
public readonly familyName: string,
|
|
70
|
+
message: string,
|
|
71
|
+
) {
|
|
72
|
+
super(`semantic selection: family "${familyName}" ${message}`);
|
|
73
|
+
this.name = 'SemanticSelectionError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const STATE_USES: readonly StateUse[] = [
|
|
78
|
+
'hover',
|
|
79
|
+
'active',
|
|
80
|
+
'focus',
|
|
81
|
+
'disabled',
|
|
82
|
+
'border',
|
|
83
|
+
'ring',
|
|
84
|
+
'subtle',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** Rank step per state on the AAA ladder. sign: 1 for light (darker), -1 for dark (lighter). */
|
|
88
|
+
const STATE_RANK_STEP: Record<
|
|
89
|
+
StateUse,
|
|
90
|
+
(rank: number, ladder: readonly number[], sign: number) => number
|
|
91
|
+
> = {
|
|
92
|
+
hover: (rank, _l, s) => rank + s,
|
|
93
|
+
active: (rank, _l, s) => rank + 2 * s,
|
|
94
|
+
focus: (rank, _l, s) => rank + s,
|
|
95
|
+
disabled: (_rank, ladder) => closestRankTo(ladder, 5),
|
|
96
|
+
border: (rank, _l, s) => rank + 2 * s,
|
|
97
|
+
ring: (rank) => rank,
|
|
98
|
+
subtle: (rank, _l, s) => rank - 2 * s,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Escape hatches the generators may precompute onto a family. */
|
|
102
|
+
type FamilyWithReferences = ColorValue & {
|
|
103
|
+
foregroundReferences?: { auto?: { family: string; position: string } };
|
|
104
|
+
stateReferences?: Partial<Record<StateUse, { family: string; position: string }>>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type PairMatrix = readonly (readonly number[])[];
|
|
108
|
+
|
|
109
|
+
function partnerForBase(pairs: PairMatrix | undefined, base: number): number | undefined {
|
|
110
|
+
if (!pairs) return undefined;
|
|
111
|
+
for (const [p1, p2] of pairs) {
|
|
112
|
+
if (p1 === base) return p2;
|
|
113
|
+
if (p2 === base) return p1;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function nearestPartner(pairs: PairMatrix | undefined, base: number): number | undefined {
|
|
119
|
+
if (!pairs || pairs.length === 0) return undefined;
|
|
120
|
+
const anchors = new Set<number>();
|
|
121
|
+
for (const pair of pairs) {
|
|
122
|
+
for (const position of pair) anchors.add(position);
|
|
123
|
+
}
|
|
124
|
+
let nearest = -1;
|
|
125
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
126
|
+
for (const anchor of anchors) {
|
|
127
|
+
const distance = Math.abs(anchor - base);
|
|
128
|
+
if (distance < bestDistance) {
|
|
129
|
+
bestDistance = distance;
|
|
130
|
+
nearest = anchor;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return nearest === -1 ? undefined : partnerForBase(pairs, nearest);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function collectLadder(pairs: PairMatrix): number[] {
|
|
137
|
+
const positions = new Set<number>();
|
|
138
|
+
for (const pair of pairs) {
|
|
139
|
+
for (const position of pair) positions.add(position);
|
|
140
|
+
}
|
|
141
|
+
return Array.from(positions).sort((a, b) => a - b);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function closestRankTo(ladder: readonly number[], target: number): number {
|
|
145
|
+
let bestRank = 0;
|
|
146
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
147
|
+
for (let rank = 0; rank < ladder.length; rank++) {
|
|
148
|
+
const position = ladder[rank];
|
|
149
|
+
if (position === undefined) continue;
|
|
150
|
+
const distance = Math.abs(position - target);
|
|
151
|
+
if (distance < bestDistance) {
|
|
152
|
+
bestDistance = distance;
|
|
153
|
+
bestRank = rank;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return bestRank;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requirePosition(index: number, familyName: string): string {
|
|
160
|
+
const position = SCALE_POSITIONS[index];
|
|
161
|
+
if (!position) {
|
|
162
|
+
throw new SemanticSelectionError(familyName, `produced invalid scale index ${index}`);
|
|
163
|
+
}
|
|
164
|
+
return position;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function requireIndex(position: string, familyName: string): number {
|
|
168
|
+
const index = POSITION_TO_INDEX[position];
|
|
169
|
+
if (index === undefined) {
|
|
170
|
+
throw new SemanticSelectionError(familyName, `has no scale position "${position}"`);
|
|
171
|
+
}
|
|
172
|
+
return index;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Status-role anchors — the colorWheel formulas, extracted as the single
|
|
177
|
+
* source of truth so the wheel and the suggestions can never drift again.
|
|
178
|
+
* Hue is the recognizable convention; CHROMA derives from the seed so the
|
|
179
|
+
* status colors harmonize with the brand. Lightness is the role's optimum.
|
|
180
|
+
*/
|
|
181
|
+
const STATUS_ROLE_ANCHORS: Record<
|
|
182
|
+
SemanticStatusRole,
|
|
183
|
+
{ hue: number; l: number; cCap: number; cScale: number; band: readonly [number, number] }
|
|
184
|
+
> = {
|
|
185
|
+
// band is the recognizability range a variant may never leave;
|
|
186
|
+
// [min > max] means the band wraps through 0 (destructive: 330..360..30).
|
|
187
|
+
destructive: { hue: 25, l: 0.55, cCap: 0.2, cScale: 1.0, band: [330, 30] },
|
|
188
|
+
success: { hue: 145, l: 0.55, cCap: 0.18, cScale: 0.9, band: [120, 160] },
|
|
189
|
+
warning: { hue: 85, l: 0.75, cCap: 0.18, cScale: 0.9, band: [60, 110] },
|
|
190
|
+
info: { hue: 230, l: 0.58, cCap: 0.15, cScale: 0.85, band: [200, 250] },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The colorWheel status color for a role, derived from the seed.
|
|
195
|
+
* Pure formula (rounded, NOT gamut-clamped): colorWheel feeds this straight
|
|
196
|
+
* into buildColorValue, which owns its own gamut pipeline.
|
|
197
|
+
*/
|
|
198
|
+
export function statusAnchor(role: SemanticStatusRole, seed: OKLCH): OKLCH {
|
|
199
|
+
const spec = STATUS_ROLE_ANCHORS[role];
|
|
200
|
+
return roundOKLCH({
|
|
201
|
+
l: spec.l,
|
|
202
|
+
c: Math.min(spec.cCap, seed.c * spec.cScale),
|
|
203
|
+
h: spec.hue,
|
|
204
|
+
alpha: seed.alpha ?? 1,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Clamp a hue into a band; [min > max] wraps through 0. */
|
|
209
|
+
function clampHueToBand(hue: number, band: readonly [number, number]): number {
|
|
210
|
+
const h = normalizeHue(hue);
|
|
211
|
+
const [min, max] = band;
|
|
212
|
+
if (min <= max) return Math.max(min, Math.min(max, h));
|
|
213
|
+
if (h >= min || h <= max) return h; // inside the wrapped band
|
|
214
|
+
// outside: snap to the nearer edge
|
|
215
|
+
const toMin = Math.abs(h - min);
|
|
216
|
+
const toMax = Math.abs(h - max);
|
|
217
|
+
return toMin < toMax ? min : max;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Variant offsets around an anchor: the anchor itself, then ±10° hue with small lightness shifts. */
|
|
221
|
+
const VARIANT_OFFSETS: readonly { dh: number; dl: number }[] = [
|
|
222
|
+
{ dh: 0, dl: 0 },
|
|
223
|
+
{ dh: 10, dl: 0.05 },
|
|
224
|
+
{ dh: -10, dl: -0.05 },
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Seed-derived semantic color suggestions, three variants per status role.
|
|
229
|
+
* Replaces the static-hue template implementation that lived in harmony.ts —
|
|
230
|
+
* hue anchors stay conventional (recognizability, enforced by per-role bands),
|
|
231
|
+
* chroma and the variant spread derive from the seed (Sean, 2026-06-09:
|
|
232
|
+
* suggestions must be based off the color object in question; the colorWheel
|
|
233
|
+
* formulas are canonical). Stored variants are gamut-clamped (the PR #973 bug).
|
|
234
|
+
*/
|
|
235
|
+
export function generateSemanticColorSuggestions(baseColor: OKLCH): SemanticColorSuggestions {
|
|
236
|
+
const roles = {} as Record<SemanticStatusRole, OKLCH[]>;
|
|
237
|
+
for (const role of Object.keys(STATUS_ROLE_ANCHORS) as SemanticStatusRole[]) {
|
|
238
|
+
const anchor = statusAnchor(role, baseColor);
|
|
239
|
+
const { band } = STATUS_ROLE_ANCHORS[role];
|
|
240
|
+
roles[role] = VARIANT_OFFSETS.map(({ dh, dl }) => {
|
|
241
|
+
const raw = roundOKLCH({
|
|
242
|
+
l: clamp01(anchor.l + dl),
|
|
243
|
+
c: anchor.c,
|
|
244
|
+
h: clampHueToBand(anchor.h + dh, band),
|
|
245
|
+
alpha: anchor.alpha ?? 1,
|
|
246
|
+
});
|
|
247
|
+
return clampColor(raw);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// 'danger' mirrors 'destructive' while apps/api still asserts the legacy key.
|
|
251
|
+
return { ...roles, danger: roles.destructive };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function semanticFor(family: ColorValue, options?: { name?: string }): SemanticContext {
|
|
255
|
+
const familyName = options?.name ?? family.name;
|
|
256
|
+
const refs = family as FamilyWithReferences;
|
|
257
|
+
const aaaPairs = family.accessibility?.wcagAAA?.normal;
|
|
258
|
+
const aaPairs = family.accessibility?.wcagAA?.normal;
|
|
259
|
+
|
|
260
|
+
function foregroundPair(from: string, level: 'AA' | 'AAA'): Pair {
|
|
261
|
+
const auto = refs.foregroundReferences?.auto;
|
|
262
|
+
if (auto) {
|
|
263
|
+
return {
|
|
264
|
+
from: { family: familyName, position: from },
|
|
265
|
+
to: { family: auto.family, position: auto.position },
|
|
266
|
+
standard: level,
|
|
267
|
+
tier: 'reference',
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const base = requireIndex(from, familyName);
|
|
271
|
+
const exactAAA = partnerForBase(aaaPairs, base);
|
|
272
|
+
const aaa = exactAAA ?? nearestPartner(aaaPairs, base);
|
|
273
|
+
const exactAA = partnerForBase(aaPairs, base);
|
|
274
|
+
const aa = exactAA ?? nearestPartner(aaPairs, base);
|
|
275
|
+
const partner = level === 'AAA' ? (aaa ?? aa) : aa;
|
|
276
|
+
if (partner === undefined) {
|
|
277
|
+
throw new SemanticSelectionError(
|
|
278
|
+
familyName,
|
|
279
|
+
`has no WCAG pair partner for position ${from} (accessibility metadata required)`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const fromAAA = level === 'AAA' && aaa !== undefined;
|
|
283
|
+
return {
|
|
284
|
+
from: { family: familyName, position: from },
|
|
285
|
+
to: { family: familyName, position: requirePosition(partner, familyName) },
|
|
286
|
+
standard: fromAAA ? 'AAA' : 'AA',
|
|
287
|
+
tier: (fromAAA ? exactAAA : exactAA) !== undefined ? 'pair-exact' : 'pair-nearest',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function statePair(use: StateUse, from: string, dark?: boolean): Pair {
|
|
292
|
+
const precomputed = refs.stateReferences?.[use];
|
|
293
|
+
if (precomputed) {
|
|
294
|
+
return {
|
|
295
|
+
from: { family: familyName, position: from },
|
|
296
|
+
to: { family: precomputed.family, position: String(precomputed.position) },
|
|
297
|
+
standard: 'AAA',
|
|
298
|
+
tier: 'reference',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (!aaaPairs || aaaPairs.length === 0) {
|
|
302
|
+
throw new SemanticSelectionError(
|
|
303
|
+
familyName,
|
|
304
|
+
'has no accessibility.wcagAAA.normal ladder (color generator must emit accessibility metadata)',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const base = requireIndex(from, familyName);
|
|
308
|
+
const ladder = collectLadder(aaaPairs);
|
|
309
|
+
const baseRank = closestRankTo(ladder, base);
|
|
310
|
+
const sign = dark ? -1 : 1;
|
|
311
|
+
const targetRank = STATE_RANK_STEP[use](baseRank, ladder, sign);
|
|
312
|
+
const clampedRank = Math.max(0, Math.min(ladder.length - 1, targetRank));
|
|
313
|
+
const targetIndex = ladder[clampedRank];
|
|
314
|
+
if (targetIndex === undefined) {
|
|
315
|
+
throw new SemanticSelectionError(familyName, `ladder lookup failed at rank ${clampedRank}`);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
from: { family: familyName, position: from },
|
|
319
|
+
to: { family: familyName, position: requirePosition(targetIndex, familyName) },
|
|
320
|
+
standard: 'AAA',
|
|
321
|
+
tier: 'ladder',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function invert(pair: Pair): Pair {
|
|
326
|
+
if (pair.to.family !== familyName && pair.tier !== 'reference') {
|
|
327
|
+
throw new SemanticSelectionError(
|
|
328
|
+
familyName,
|
|
329
|
+
`cannot invert a pair whose legs belong to "${pair.from.family}"/"${pair.to.family}"`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const fromTarget = 10 - requireIndex(pair.from.position, familyName);
|
|
333
|
+
const toIndex = POSITION_TO_INDEX[pair.to.position];
|
|
334
|
+
const toTarget = toIndex === undefined ? undefined : 10 - toIndex;
|
|
335
|
+
|
|
336
|
+
// Nearest passing pair to the inverted targets: AAA matrix first, AA next.
|
|
337
|
+
// Scoring is lexicographic, FROM-leg first: the background leg carries the
|
|
338
|
+
// token's character (the whole point of #1635); the foreground leg is
|
|
339
|
+
// advisory here because the cascade re-derives it as contrast against the
|
|
340
|
+
// dark background. Sum-scoring let a distant fg leg drag the bg to the
|
|
341
|
+
// wrong side of the scale.
|
|
342
|
+
for (const [pairs, standard] of [
|
|
343
|
+
[aaaPairs, 'AAA'],
|
|
344
|
+
[aaPairs, 'AA'],
|
|
345
|
+
] as const) {
|
|
346
|
+
if (!pairs || pairs.length === 0) continue;
|
|
347
|
+
let best: { from: number; to: number; fromDist: number; toDist: number } | null = null;
|
|
348
|
+
for (const [p1, p2] of pairs) {
|
|
349
|
+
if (p1 === undefined || p2 === undefined) continue;
|
|
350
|
+
for (const [a, b] of [
|
|
351
|
+
[p1, p2],
|
|
352
|
+
[p2, p1],
|
|
353
|
+
]) {
|
|
354
|
+
const fromDist = Math.abs((a as number) - fromTarget);
|
|
355
|
+
const toDist = toTarget === undefined ? 0 : Math.abs((b as number) - toTarget);
|
|
356
|
+
const better =
|
|
357
|
+
best === null ||
|
|
358
|
+
fromDist < best.fromDist ||
|
|
359
|
+
(fromDist === best.fromDist && toDist < best.toDist) ||
|
|
360
|
+
(fromDist === best.fromDist && toDist === best.toDist && (a as number) < best.from);
|
|
361
|
+
if (better) best = { from: a as number, to: b as number, fromDist, toDist };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (best) {
|
|
365
|
+
return {
|
|
366
|
+
from: { family: familyName, position: requirePosition(best.from, familyName) },
|
|
367
|
+
to: { family: familyName, position: requirePosition(best.to, familyName) },
|
|
368
|
+
standard,
|
|
369
|
+
tier: best.fromDist === 0 && best.toDist === 0 ? 'pair-exact' : 'pair-nearest',
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Last resort: pure mathematical inversion, loudly marked.
|
|
375
|
+
const toFallback = toTarget === undefined ? fromTarget : toTarget;
|
|
376
|
+
return {
|
|
377
|
+
from: { family: familyName, position: requirePosition(fromTarget, familyName) },
|
|
378
|
+
to: { family: familyName, position: requirePosition(toFallback, familyName) },
|
|
379
|
+
standard: 'none',
|
|
380
|
+
tier: 'inversion',
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
pair(request: PairRequest): Pair {
|
|
386
|
+
const level = request.level ?? 'AAA';
|
|
387
|
+
if (request.use === 'foreground') return foregroundPair(request.from, level);
|
|
388
|
+
return statePair(request.use, request.from, request.dark);
|
|
389
|
+
},
|
|
390
|
+
states(from: string, dark?: boolean): Record<StateUse, Pair> {
|
|
391
|
+
const out = {} as Record<StateUse, Pair>;
|
|
392
|
+
for (const use of STATE_USES) out[use] = statePair(use, from, dark);
|
|
393
|
+
return out;
|
|
394
|
+
},
|
|
395
|
+
invert,
|
|
396
|
+
};
|
|
397
|
+
}
|