brand-intent 0.20.0 → 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "An open file format specification for making brand intent machine-readable.",
5
5
  "author": "Christophe Stoll <christophe@stoll.studio>",
6
6
  "license": "MIT",
@@ -37,6 +37,7 @@ import {
37
37
  derivePairSlots,
38
38
  resolveTextColor,
39
39
  checkFamilyPair,
40
+ toneAnchorsFrom,
40
41
  reachCoversAll,
41
42
  cycleMembers,
42
43
  LEVEL_SLOTS,
@@ -1626,6 +1627,10 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1626
1627
  // Family pairs → checked; valid ones become themes named `{Family}-{n}` (n = declared position).
1627
1628
  if (colorFamilies) {
1628
1629
  const config = { order: colorFamilies.order, borrow: colorFamilies.borrow, distance: colorFamilies.distance };
1630
+ const toneAnchors = toneAnchorsFrom(tones?.colors, name => {
1631
+ const raw = brandColors[name];
1632
+ return raw ? normalizeHex(raw) ?? undefined : undefined;
1633
+ });
1629
1634
  colorFamilies.families.forEach((family, index) => {
1630
1635
  const raw = familiesRaw[index];
1631
1636
  raw.pairs.forEach((p, n) => {
@@ -1635,7 +1640,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1635
1640
  if (!hex || !isHex6(hex)) throw new Error(`${fileName}:${p.line}: pair color "${value}" is not a known 6-digit color`);
1636
1641
  return hex;
1637
1642
  };
1638
- const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText);
1643
+ const check = checkFamilyPair(resolve(p.ground), resolve(p.figure), index, colorFamilies!, config, rules, neutralText, toneAnchors);
1639
1644
  const themeName = `${family.name}-${n + 1}`;
1640
1645
  if (check.valid) {
1641
1646
  if (themes[themeName]) throw new Error(`${fileName}: family pair theme "${themeName}" collides with an authored theme`);
@@ -195,23 +195,48 @@ export function reachCoversAll(count: number, config: ReachConfig): boolean {
195
195
  return true;
196
196
  }
197
197
 
198
- /** Where a color comes from, seen from family `index`. Null = not usable there. */
198
+ /** Where a color comes from, seen from family `index`. Null = not usable there.
199
+ * `toneAnchors` maps a tone's hex to the hex of the color(s) whose ladder holds it, so a tone
200
+ * counts as its color: a family color's tone is own, a shared color's tone shared, a neighbour's by reach. */
199
201
  export function colorOrigin(
200
202
  hex: string,
201
203
  index: number,
202
204
  system: Pick<ColorFamilies, 'families' | 'shared'>,
203
205
  config: ReachConfig,
206
+ toneAnchors: ToneAnchors = {},
204
207
  ): PairOrigin | null {
205
208
  const families = system.families;
206
- if (families[index]?.colors.some(c => c.hex === hex)) return 'own';
207
- if (system.shared.some(s => s.hex === hex)) return 'shared';
209
+ const candidates = [hex, ...(toneAnchors[hex] ?? [])];
210
+ const holds = (colors: Array<{ hex: string }>) => colors.some(c => candidates.includes(c.hex));
211
+ if (families[index] && holds(families[index].colors)) return 'own';
212
+ if (holds(system.shared)) return 'shared';
208
213
  const reach = reachableFamilies(families.length, index, config, cycleMembers(families));
209
214
  for (const [j, dir] of reach) {
210
- if (families[j].colors.some(c => c.hex === hex)) return dir;
215
+ if (holds(families[j].colors)) return dir;
211
216
  }
212
217
  return null;
213
218
  }
214
219
 
220
+ /** Tone hex → hex of every color whose declared ladder holds it (`tones` block). Empty without tones. */
221
+ export type ToneAnchors = Record<string, string[]>;
222
+
223
+ /** Build `ToneAnchors` from ladders (color name → step → hex) and the colors' own hexes. */
224
+ export function toneAnchorsFrom(
225
+ ladders: Record<string, Record<number, string>> | undefined,
226
+ colorHex: (name: string) => string | undefined,
227
+ ): ToneAnchors {
228
+ const out: ToneAnchors = {};
229
+ for (const [name, ladder] of Object.entries(ladders ?? {})) {
230
+ const anchor = colorHex(name);
231
+ if (!anchor) continue;
232
+ for (const hex of Object.values(ladder)) {
233
+ if (hex === anchor) continue;
234
+ (out[hex] ??= []).includes(anchor) || out[hex].push(anchor);
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
215
240
  /**
216
241
  * Family in season on `date`: the one with the latest `from:` on or before that
217
242
  * month/day, wrapping to the year's last start before the first one. Uses the
@@ -229,9 +254,10 @@ export function familyIndexForDate(families: Pick<ColorFamily, 'from'>[], date:
229
254
  return best !== -1 ? best : latest;
230
255
  }
231
256
 
232
- /** Name of the family that declares `hex` (first match), for error messages. */
233
- export function owningFamily(hex: string, families: ColorFamily[]): string | undefined {
234
- return families.find(f => f.colors.some(c => c.hex === hex))?.name;
257
+ /** Name of the family that declares `hex` or the color it is a tone of (first match), for error messages. */
258
+ export function owningFamily(hex: string, families: ColorFamily[], toneAnchors: ToneAnchors = {}): string | undefined {
259
+ const candidates = [hex, ...(toneAnchors[hex] ?? [])];
260
+ return families.find(f => f.colors.some(c => candidates.includes(c.hex)))?.name;
235
261
  }
236
262
 
237
263
  /** Text candidates for a family ground: its contrast color(s), then shared colors, then extras. */
@@ -252,15 +278,16 @@ export function checkFamilyPair(
252
278
  config: ReachConfig,
253
279
  rules: ColorRules,
254
280
  extraTextCandidates: string[] = [],
281
+ toneAnchors: ToneAnchors = {},
255
282
  ): PairCheck {
256
283
  const reasons: string[] = [];
257
284
  const origins: [PairOrigin | null, PairOrigin | null] = [
258
- colorOrigin(ground, index, system, config),
259
- colorOrigin(figure, index, system, config),
285
+ colorOrigin(ground, index, system, config, toneAnchors),
286
+ colorOrigin(figure, index, system, config, toneAnchors),
260
287
  ];
261
288
  for (const [hex, origin] of [[ground, origins[0]], [figure, origins[1]]] as const) {
262
289
  if (origin === null) {
263
- const owner = owningFamily(hex, system.families);
290
+ const owner = owningFamily(hex, system.families, toneAnchors);
264
291
  reasons.push(owner ? `${hex} from ${owner} is out of reach` : `${hex} is not in any family or shared`);
265
292
  }
266
293
  }
package/parser/index.ts CHANGED
@@ -21,13 +21,14 @@ export {
21
21
  levelRuleViolations,
22
22
  reachCoversAll,
23
23
  colorOrigin,
24
+ toneAnchorsFrom,
24
25
  familyTextCandidates,
25
26
  checkFamilyPair,
26
27
  enumerateFamilyPairs,
27
28
  familyIndexForDate,
28
29
  DEFAULT_COLOR_RULES,
29
30
  } from './colorSystem';
30
- export type { ReachConfig } from './colorSystem';
31
+ export type { ReachConfig, ToneAnchors } from './colorSystem';
31
32
  export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef, ParsedPhotoLookDef, PhotoLookKey } from './brandParser';
32
33
 
33
34
  export { parseDateWindowRanges, isInDateWindow, SAMPLE_WINDOW_MARKER } from './dateWindows';