brand-intent 0.20.0 → 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
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,50 @@ 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
+ const list: string[] = out[hex] ?? [];
235
+ if (!list.includes(anchor)) list.push(anchor);
236
+ out[hex] = list;
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
215
242
  /**
216
243
  * Family in season on `date`: the one with the latest `from:` on or before that
217
244
  * month/day, wrapping to the year's last start before the first one. Uses the
@@ -229,9 +256,10 @@ export function familyIndexForDate(families: Pick<ColorFamily, 'from'>[], date:
229
256
  return best !== -1 ? best : latest;
230
257
  }
231
258
 
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;
259
+ /** Name of the family that declares `hex` or the color it is a tone of (first match), for error messages. */
260
+ export function owningFamily(hex: string, families: ColorFamily[], toneAnchors: ToneAnchors = {}): string | undefined {
261
+ const candidates = [hex, ...(toneAnchors[hex] ?? [])];
262
+ return families.find(f => f.colors.some(c => candidates.includes(c.hex)))?.name;
235
263
  }
236
264
 
237
265
  /** Text candidates for a family ground: its contrast color(s), then shared colors, then extras. */
@@ -252,15 +280,16 @@ export function checkFamilyPair(
252
280
  config: ReachConfig,
253
281
  rules: ColorRules,
254
282
  extraTextCandidates: string[] = [],
283
+ toneAnchors: ToneAnchors = {},
255
284
  ): PairCheck {
256
285
  const reasons: string[] = [];
257
286
  const origins: [PairOrigin | null, PairOrigin | null] = [
258
- colorOrigin(ground, index, system, config),
259
- colorOrigin(figure, index, system, config),
287
+ colorOrigin(ground, index, system, config, toneAnchors),
288
+ colorOrigin(figure, index, system, config, toneAnchors),
260
289
  ];
261
290
  for (const [hex, origin] of [[ground, origins[0]], [figure, origins[1]]] as const) {
262
291
  if (origin === null) {
263
- const owner = owningFamily(hex, system.families);
292
+ const owner = owningFamily(hex, system.families, toneAnchors);
264
293
  reasons.push(owner ? `${hex} from ${owner} is out of reach` : `${hex} is not in any family or shared`);
265
294
  }
266
295
  }
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';