@instruments/materialgraph 0.9.0 → 0.10.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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The BRAND lens: the catalogue collection, one brand's profile, and one
3
+ * brand's whole colour range, as thin wrappers over the shared
4
+ * {@link HttpTransport}. `MaterialGraphClient.brands` / `.brand` /
5
+ * `.brandColourDeck` delegate here; the contract lives in this file.
6
+ *
7
+ * A brand is a COMPANY, not a catalogue. Every count these reads return is
8
+ * coverage — what MaterialGraph has ingested — and never a claim about how
9
+ * many products the maker sells.
10
+ */
11
+ import type { BrandCatalogListPage, BrandsOptions } from "./brand-types.js";
12
+ import type { BrandFanDeck } from "./fan-deck-types.js";
13
+ import { HttpTransport } from "./http-transport.js";
14
+ import type { BrandOptions, BrandProfileResponse } from "./types.js";
15
+ /**
16
+ * THE QUESTION: "whose catalogs are in here, and how big is each?" The SDK's
17
+ * one OFFSET-paginated surface: read `meta.total` for the collection size and
18
+ * `meta.hasMore` to keep walking. Instead use {@link brandProfile} for one
19
+ * brand's profile and trade signals.
20
+ */
21
+ export declare function listBrands(transport: HttpTransport, options?: BrandsOptions, signal?: AbortSignal): Promise<BrandCatalogListPage>;
22
+ /**
23
+ * THE QUESTION: "who is this brand, and what do they claim?" — profile plus the
24
+ * trade/spec signals ON RECORD. `trade` is keyed by attribute id and an ABSENT
25
+ * key means no value is held — never infer `false`. Instead use `search()` to
26
+ * find their materials; this describes the company, not the catalog.
27
+ */
28
+ export declare function brandProfile(transport: HttpTransport, brandSlug: string, options?: BrandOptions, signal?: AbortSignal): Promise<BrandProfileResponse>;
29
+ /**
30
+ * THE QUESTION: "show me this maker's whole range, the way a fan deck reads."
31
+ * Colours grouped by family — creams together, then greys, then browns — and
32
+ * ordered light to dark within each. No stored scalar produces that order: a
33
+ * lightness sort interleaves the whites and the creams, and a reader loses the
34
+ * sense that they are looking at two different families.
35
+ *
36
+ * Deliberately unpaginated, so there is no cursor to walk and no all-brands
37
+ * form: family order is only correct over a COMPLETE range, because the greys
38
+ * cannot be placed against the browns while holding one page of each. A range
39
+ * past the server ceiling reports `truncatedAt` rather than being quietly cut,
40
+ * and an absent `truncatedAt` means the deck is whole.
41
+ *
42
+ * Every swatch carries the maker's PUBLISHED hex — this read never returns a
43
+ * colour derived from imagery. For "what is near this colour, across makers"
44
+ * use `byColour()` instead.
45
+ *
46
+ * @param brandSlug The brand's slug, as `listBrands()` reports it.
47
+ */
48
+ export declare function brandColourDeck(transport: HttpTransport, brandSlug: string, signal?: AbortSignal): Promise<BrandFanDeck>;
@@ -0,0 +1,45 @@
1
+ import { HttpTransport } from "./http-transport.js";
2
+ import { resolveOperation } from "./operations.js";
3
+ /**
4
+ * THE QUESTION: "whose catalogs are in here, and how big is each?" The SDK's
5
+ * one OFFSET-paginated surface: read `meta.total` for the collection size and
6
+ * `meta.hasMore` to keep walking. Instead use {@link brandProfile} for one
7
+ * brand's profile and trade signals.
8
+ */
9
+ export function listBrands(transport, options = {}, signal) {
10
+ return transport.requestJson(resolveOperation("list_brands"), {
11
+ query: { q: options.q, limit: options.limit, offset: options.offset },
12
+ signal,
13
+ });
14
+ }
15
+ /**
16
+ * THE QUESTION: "who is this brand, and what do they claim?" — profile plus the
17
+ * trade/spec signals ON RECORD. `trade` is keyed by attribute id and an ABSENT
18
+ * key means no value is held — never infer `false`. Instead use `search()` to
19
+ * find their materials; this describes the company, not the catalog.
20
+ */
21
+ export function brandProfile(transport, brandSlug, options, signal) {
22
+ return transport.requestJson(resolveOperation("get_brand_profile"), { query: { q: options?.q }, pathParams: { brandSlug }, signal });
23
+ }
24
+ /**
25
+ * THE QUESTION: "show me this maker's whole range, the way a fan deck reads."
26
+ * Colours grouped by family — creams together, then greys, then browns — and
27
+ * ordered light to dark within each. No stored scalar produces that order: a
28
+ * lightness sort interleaves the whites and the creams, and a reader loses the
29
+ * sense that they are looking at two different families.
30
+ *
31
+ * Deliberately unpaginated, so there is no cursor to walk and no all-brands
32
+ * form: family order is only correct over a COMPLETE range, because the greys
33
+ * cannot be placed against the browns while holding one page of each. A range
34
+ * past the server ceiling reports `truncatedAt` rather than being quietly cut,
35
+ * and an absent `truncatedAt` means the deck is whole.
36
+ *
37
+ * Every swatch carries the maker's PUBLISHED hex — this read never returns a
38
+ * colour derived from imagery. For "what is near this colour, across makers"
39
+ * use `byColour()` instead.
40
+ *
41
+ * @param brandSlug The brand's slug, as `listBrands()` reports it.
42
+ */
43
+ export function brandColourDeck(transport, brandSlug, signal) {
44
+ return transport.requestJson(resolveOperation("brand_colour_deck"), { pathParams: { brandSlug }, signal });
45
+ }
@@ -7,7 +7,7 @@
7
7
  * enums are widened so a new server value still type-checks.
8
8
  */
9
9
  import type { VariantId } from "./identifiers.js";
10
- import type { OklabTriple, PaletteSwatch } from "./types.js";
10
+ import type { OklabTriple, OpenEnum, PaletteSwatch } from "./types.js";
11
11
  /** Coarse group + fine type of a colour match's primary product type. */
12
12
  export interface MatchCategory {
13
13
  group: string | null;
@@ -16,10 +16,10 @@ export interface MatchCategory {
16
16
  /**
17
17
  * One colour-palette match.
18
18
  *
19
- * `hex`, `distance` and `closeness` are OKLab geometry over colour EXTRACTED
20
- * FROM PRODUCT IMAGERY, not declared by the manufacturer. Report it as visual
21
- * likeness ("reads close to this colour"), never as a stated colour
22
- * specification.
19
+ * `distance` and `closeness` are OKLab geometry over colour EXTRACTED FROM
20
+ * PRODUCT IMAGERY. Report the ORDER as visual likeness ("reads close to this
21
+ * colour"), never as a stated colour specification. `hex` itself may be the
22
+ * manufacturer's published colour — read `sourceBasis` before repeating it.
23
23
  */
24
24
  export interface ByColourMatch {
25
25
  /** Opaque — round-trip verbatim. */
@@ -34,6 +34,31 @@ export interface ByColourMatch {
34
34
  closeness: number;
35
35
  category: MatchCategory | null;
36
36
  oklab: OklabTriple | null;
37
+ /**
38
+ * Where `hex` came from — PER MATCH, never per response. `"declared"`: the
39
+ * manufacturer published it (a paint's own colour system), so it may be
40
+ * repeated as their stated colour. `"derived"`: extracted from product
41
+ * imagery — visual likeness only. Widened to `string` so a new server value
42
+ * still type-checks. ABSENT when `hex` is null, or on a response captured
43
+ * before the field existed.
44
+ */
45
+ sourceBasis?: OpenEnum<"declared" | "derived">;
46
+ /**
47
+ * OKLab distance from the QUERY colour to this match's `hex` — two single
48
+ * colours. NOT `distance`, which is the imagery ranking's geometry over whole
49
+ * PALETTES. The two disagree routinely: a match ranked 4th by imagery is often
50
+ * the closest by hex. ABSENT when the match has no hex, or when the query was
51
+ * a palette rather than one colour.
52
+ */
53
+ colourDistance?: number;
54
+ /**
55
+ * `colourDistance` as a named band, so nobody thresholds a float. Not a
56
+ * confidence and not a percentage — `very-close` still covers differences a
57
+ * client would reject in a paint order, so read `sourceBasis` before quoting
58
+ * any of it as a specification. Widened to `string` so a new server band still
59
+ * type-checks. ABSENT exactly when `colourDistance` is.
60
+ */
61
+ proximity?: OpenEnum<"indistinguishable" | "very-close" | "close" | "related" | "unrelated">;
37
62
  /**
38
63
  * The match's own image palette — the FULL stored palette, uncapped and in
39
64
  * stored order, each swatch with `oklab` (see {@link ByColourSwatch}), so a
@@ -86,6 +111,11 @@ export interface ByColourResponse {
86
111
  query: {
87
112
  colours: string[];
88
113
  categories?: string[];
114
+ /**
115
+ * Echoed brand filter as resolved SLUGS — what the names you passed
116
+ * resolved to. Present only when a brand scope applied.
117
+ */
118
+ brands?: string[];
89
119
  /** Echoed `family` filter, normalised. Present only when one was applied. */
90
120
  family?: string;
91
121
  oklab?: OklabTriple;
@@ -118,6 +148,20 @@ export interface ByColourParams {
118
148
  b?: number;
119
149
  /** HARD category filter — display names or taxonomy ids (repeatable). */
120
150
  category?: string | string[];
151
+ /**
152
+ * HARD brand filter (repeatable) — a brand SLUG (`sherwin-williams`) or its
153
+ * display name (`Sherwin-Williams`), case-insensitive.
154
+ *
155
+ * This is how the CROSS-BRAND question is asked: pass one brand's colour as
156
+ * `hex` and scope to another to get that brand's nearest colours to it. It is
157
+ * applied inside retrieval, so `limit` still fills from the scoped brands —
158
+ * filtering a returned page instead comes back short, because a
159
+ * nearest-neighbour scan does not know to look further.
160
+ *
161
+ * An unknown brand is a 400 naming it, never an empty page. Omitting it (or
162
+ * passing an empty list) searches every brand.
163
+ */
164
+ brand?: string | string[];
121
165
  /**
122
166
  * HARD colour-family filter — ONE colorscope taxonomy family id, from the
123
167
  * closed 15-value vocabulary colorscope itself publishes (`white`, `cream`,
package/client.d.ts CHANGED
@@ -1,25 +1,26 @@
1
- import { type RateLimit } from "./errors.js";
2
1
  import type { MaterialGraphClientOptions } from "./client-config.js";
2
+ import { type RateLimit } from "./errors.js";
3
3
  export * from "./client-config.js";
4
4
  import { type BrowseNamespace } from "./client-browse.js";
5
5
  export type { BrowseNamespace } from "./client-browse.js";
6
- import type { CapabilityManifestResponse, CreatePublicRunRequest, CreatePublicRunResponse, DrainRunEventsOptions, ListRunsOptions, PublicRunListResponse, PublicRunResponse, ResumeRunOptions, RunEvent, SourceMaterialBatchDetail, SourceMaterialBatchReceipt, StreamRunEventsOptions, UploadSourceMaterialInput } from "./run-types.js";
7
- import type { FinishScheduleResponse, GetRunScheduleOptions } from "./schedule-types.js";
8
- import type { MatchStreamEvent } from "./match-stream-types.js";
9
- import type { AppendProjectEventRequest, CreateProjectRequest, ProjectEventReceipt, ProjectListResponse, ProjectState } from "./project-types.js";
10
- import type { VarySchemesRequest, VarySchemesResponse } from "./composition-types.js";
11
- import { CodesClient } from "./codes-client.js";
12
- import { ColoursClient } from "./colours-client.js";
13
- import type { CodeAdvisoryInput, CodeAdvisoryResponse } from "./codes-types.js";
14
6
  import type { AssociatedOptions } from "./association-types.js";
15
7
  import type { BrandCatalogListPage, BrandsOptions } from "./brand-types.js";
8
+ import type { ByColourParams, ByColourResponse } from "./by-colour-types.js";
9
+ import { CodesClient } from "./codes-client.js";
10
+ import type { CodeAdvisoryInput, CodeAdvisoryResponse } from "./codes-types.js";
11
+ import { ColoursClient } from "./colours-client.js";
12
+ import type { VarySchemesRequest, VarySchemesResponse } from "./composition-types.js";
13
+ import type { BatchId, ProjectId, RunId, VariantId } from "./identifiers.js";
14
+ import type { MatchStreamEvent } from "./match-stream-types.js";
15
+ import type { MatchInput, MatchResponse } from "./match-types.js";
16
16
  import type { MaterialAssetProjection } from "./material-asset-types.js";
17
17
  import type { MaterialBlockProjection } from "./material-block-types.js";
18
+ import type { AppendProjectEventRequest, CreateProjectRequest, ProjectEventReceipt, ProjectListResponse, ProjectState } from "./project-types.js";
19
+ import { resolvePhrasesRequest as resolvePhrases } from "./resolve-client.js";
20
+ import type { CapabilityManifestResponse, CreatePublicRunRequest, CreatePublicRunResponse, DrainRunEventsOptions, ListRunsOptions, PublicRunListResponse, PublicRunResponse, ResumeRunOptions, RunEvent, SourceMaterialBatchDetail, SourceMaterialBatchReceipt, StreamRunEventsOptions, UploadSourceMaterialInput } from "./run-types.js";
21
+ import type { FinishScheduleResponse, GetRunScheduleOptions } from "./schedule-types.js";
18
22
  import type { SchemaResource, SchemaResourceResponse } from "./schema-resource-types.js";
19
- import type { BatchId, ProjectId, RunId, VariantId } from "./identifiers.js";
20
- import type { ByColourParams, ByColourResponse } from "./by-colour-types.js";
21
23
  import type { ApplicableSpecsResponse, BrandOptions, BrandProfileResponse, BySpecOptions, BySpecResponse, ColourFacetsResponse, CoordinateInput, CoordinateResponse, DemandBatchInput, DemandBatchResponse, DemandParams, DemandResponse, ExtractPaletteInput, ExtractPaletteResponse, GlossaryTermResponse, MaterialResponse, MaterialsBatchDetailResponse, MaterialsBatchGetOptions, MaterialsBatchOptions, MaterialsBatchTileResponse, PairingsOptions, PairingsResponse, ResolveAttributeOptions, ResolveAttributeResponse, ResolveAttributeValueOptions, ResolveAttributeValueResponse, ResolveColourResponse, SearchGetParams, SearchInput, SearchResponse, SiblingsResponse, SpecFacetsDistributionResponse, SpecFacetsListResponse, SuggestColourResponse, SuggestOptions, SuggestResponse, TaxonomyResponse, VocabularySearchOptions, WhoamiResponse } from "./types.js";
22
- import type { MatchInput, MatchResponse } from "./match-types.js";
23
24
  /**
24
25
  * The MaterialGraph client — every lens on the graph, on one object.
25
26
  *
@@ -172,8 +173,7 @@ export declare class MaterialGraphClient {
172
173
  * {@link MaterialGraphClient.browse} to walk by lightness/vividness, and
173
174
  * {@link MaterialGraphClient.search} when colour is one signal among many.
174
175
  * Report matches as visual likeness, never a stated colour specification.
175
- * `family` gates the answer to ONE colorscope colour family when nearness
176
- * alone crosses a hue boundary; a gated page can be shorter than `limit`.
176
+ * `family` gates to ONE colorscope family (a gated page can be short); `brand` gates to named brands — one brand's nearest colour to another's.
177
177
  */
178
178
  byColour(params: ByColourParams, signal?: AbortSignal): Promise<ByColourResponse>;
179
179
  /**
@@ -326,33 +326,29 @@ export declare class MaterialGraphClient {
326
326
  resolveAttributeValue(attributeId: string, query: string, options?: ResolveAttributeValueOptions, signal?: AbortSignal): Promise<ResolveAttributeValueResponse>;
327
327
  /** "Is this phrase an attribute or a value?" — see {@link searchVocabularyRequest}. */
328
328
  searchVocabulary(query: string, options?: VocabularySearchOptions, signal?: AbortSignal): Promise<import("./catalog-discovery-types.js").VocabularySearchResponse>;
329
- /** "I have words — what do they mean in YOUR vocabulary?" — see {@link resolvePhrasesRequest}. */
330
- resolve(phrases: string[], signal?: AbortSignal): Promise<import("./resolve-types.js").ResolveResponse>;
329
+ /** "I have words — what do they mean, and what stands behind them?" — see {@link resolvePhrases}. */
330
+ resolve(phrases: string[], scope?: Parameters<typeof resolvePhrases>[2]): Promise<import("./resolve-types.js").ResolveResponse>;
331
331
  /**
332
332
  * THE QUESTION: "which materials, from ANYONE, are ASTM E84 Class A AND
333
- * GREENGUARD-certified?" — hard specifications, no ranking, every brand at once
334
- * whatever each called the value in source.
333
+ * GREENGUARD-certified?" — hard specifications, no ranking, every brand at
334
+ * once whatever each called the value in source. The value-anchored,
335
+ * spec-only primitive (GET `/api/v1/search/specifications`).
335
336
  *
336
337
  * Instead use `search({ filters })` when the spec merely GATES a ranked
337
338
  * question, `codes.advisory()` when the real question is what a space demands,
338
- * and {@link MaterialGraphClient.specificationFacets} first if you do not know
339
- * the canonical tokens. Guessing one no longer returns a silently empty
340
- * answer: an unresolvable criterion means the query NEVER RAN, and the
341
- * response says so — read {@link BySpecResponse.unresolvedCriteria} before
342
- * treating an empty `matches` as "nothing qualifies".
343
- *
344
- * A PAGE, NOT A CORPUS, unusually so: `totalMatches` is the size of the
345
- * returned sample under `limit`, not a corpus count. See {@link BySpecResponse}.
346
- *
347
- * Find materials by canonical specification across every brand — the
348
- * value-anchored, spec-only primitive (GET `/api/v1/search/specifications`).
349
- * Each `spec` is an `attribute:value` criterion; a variant must satisfy ALL of
350
- * them, and each match carries per-criterion trust (`confidence` +
351
- * `sourceBasis`). Answers "which materials, from anyone, are ASTM E84 Class A
352
- * AND GREENGUARD-certified?" Build the strings with `specCriterion` /
353
- * `certificationProgrammeCriterion` (e.g.
354
- * `certificationProgrammeCriterion("GREENGUARD Gold")`); discover the queryable
355
- * vocabulary with {@link specificationFacets} and {@link attributeValues}.
339
+ * and {@link specificationFacets} first if you do not know the canonical
340
+ * tokens. Guessing one no longer returns a silently empty answer: an
341
+ * unresolvable criterion means the query NEVER RAN and the response says so —
342
+ * read {@link BySpecResponse.unresolvedCriteria} before treating an empty
343
+ * `matches` as "nothing qualifies". A PAGE, NOT A CORPUS, unusually so:
344
+ * `totalMatches` is the returned sample's size under `limit`, not a corpus
345
+ * count. Each `spec` is an `attribute:value` criterion built with
346
+ * `specCriterion` / `certificationProgrammeCriterion`; a variant must satisfy
347
+ * ALL of them, and each match carries per-criterion trust (`confidence` +
348
+ * `sourceBasis`). NUMBER-valued specs take BOUNDS, not values — pass
349
+ * `options.ranges` built with `specRangeCriterion("light_reflectance_value",
350
+ * { min: 60 })`; bounds are inclusive, at least one is required, and a
351
+ * non-numeric stored value is excluded rather than read as zero.
356
352
  */
357
353
  bySpec(spec: string | string[], options?: BySpecOptions, signal?: AbortSignal): Promise<BySpecResponse>;
358
354
  /**
@@ -479,21 +475,12 @@ export declare class MaterialGraphClient {
479
475
  * {@link MaterialGraphClient.attributeValues} for the values it takes.
480
476
  */
481
477
  glossary(term: string, signal?: AbortSignal): Promise<GlossaryTermResponse>;
482
- /**
483
- * THE QUESTION: "whose catalogs are in here, and how big is each?" The SDK's
484
- * one OFFSET-paginated surface: read `meta.total` for the collection size and
485
- * `meta.hasMore` to keep walking. Instead use {@link MaterialGraphClient.brand}
486
- * for one brand's profile and trade signals.
487
- */
478
+ /** "Whose catalogs are in here, and how big is each?" — {@link listBrands}. */
488
479
  brands(options?: BrandsOptions, signal?: AbortSignal): Promise<BrandCatalogListPage>;
489
- /**
490
- * THE QUESTION: "who is this brand, and what do they claim?" — profile plus the
491
- * trade/spec signals ON RECORD. `trade` is keyed by attribute id and an ABSENT
492
- * key means no value is held — never infer `false`. Instead use
493
- * {@link MaterialGraphClient.search} to find their materials; this describes the
494
- * company, not the catalog.
495
- */
480
+ /** "Who is this brand, and what do they claim?" — {@link brandProfile}. */
496
481
  brand(brandSlug: string, options?: BrandOptions, signal?: AbortSignal): Promise<BrandProfileResponse>;
482
+ /** "This maker's whole range, the way a fan deck reads" — {@link brandColourDeck}. */
483
+ brandColourDeck(brandSlug: string, signal?: AbortSignal): Promise<import("./fan-deck-types.js").BrandFanDeck>;
497
484
  /**
498
485
  * Upload one atomic source-material batch (POST `/api/v1/source-material`,
499
486
  * multipart). Pass a heterogeneous pile via `{ files }` (any mix of PDF,
package/client.js CHANGED
@@ -1,20 +1,21 @@
1
+ import { DEFAULT_BASE_URL, MAX_BATCH_IDS } from "./client-config.js";
1
2
  import { MaterialGraphError } from "./errors.js";
2
3
  import { HttpTransport } from "./http-transport.js";
3
4
  // Aliased to `operation` — the local name every call site here already used.
4
- import { resolveOperation as operation } from "./operations.js";
5
+ import { resolveOperation as operation, } from "./operations.js";
5
6
  import { cancelRun, capabilities as capabilitiesRun, createRun, drainRunEvents, getRun, getRunSchedule, getSourceMaterialBatch as getSourceMaterialBatchCall, listRuns, resumeRun, streamRunEvents, uploadSourceMaterial, } from "./runs.js";
6
- import { DEFAULT_BASE_URL, MAX_BATCH_IDS } from "./client-config.js";
7
7
  // Re-exported whole so `index.ts` and every existing importer keep their path.
8
8
  export * from "./client-config.js";
9
- import { browseQuery, createBrowseNamespace } from "./client-browse.js";
10
- import { suggestQuery } from "./suggest-types.js";
11
- import { matchStream as matchStreamCall } from "./match-stream.js";
12
- import { appendProjectEvent as appendProjectEventCall, createProject as createProjectCall, getProject as getProjectCall, listProjects as listProjectsCall, } from "./projects.js";
9
+ import { browseQuery, createBrowseNamespace, } from "./client-browse.js";
10
+ import { declaredFinishes, declaredProducts } from "./associations-client.js";
11
+ import { brandColourDeck, brandProfile, listBrands } from "./brands-client.js";
13
12
  import { CodesClient } from "./codes-client.js";
14
13
  import { ColoursClient } from "./colours-client.js";
15
- import { resolvePhrasesRequest, searchVocabularyRequest } from "./resolve-client.js";
16
- import { declaredFinishes, declaredProducts } from "./associations-client.js";
14
+ import { matchStream as matchStreamCall } from "./match-stream.js";
15
+ import { appendProjectEvent as appendProjectEventCall, createProject as createProjectCall, getProject as getProjectCall, listProjects as listProjectsCall, } from "./projects.js";
16
+ import { resolvePhrasesRequest as resolvePhrases, searchVocabularyRequest, } from "./resolve-client.js";
17
17
  import { SCHEMA_RESOURCE_OPERATION_IDS } from "./schema-resource-types.js";
18
+ import { suggestQuery } from "./suggest-types.js";
18
19
  /**
19
20
  * The MaterialGraph client — every lens on the graph, on one object.
20
21
  *
@@ -112,10 +113,17 @@ export class MaterialGraphClient {
112
113
  return this.#transport.lastRateLimit;
113
114
  }
114
115
  #get(operationId, query, signal, pathParams) {
115
- return this.#transport.requestJson(operation(operationId), { query, signal, pathParams });
116
+ return this.#transport.requestJson(operation(operationId), {
117
+ query,
118
+ signal,
119
+ pathParams,
120
+ });
116
121
  }
117
122
  #post(operationId, body, signal) {
118
- return this.#transport.requestJson(operation(operationId), { body, signal });
123
+ return this.#transport.requestJson(operation(operationId), {
124
+ body,
125
+ signal,
126
+ });
119
127
  }
120
128
  /**
121
129
  * THE QUESTION: "find me materials like this" — a RANKED answer over words, a
@@ -202,8 +210,7 @@ export class MaterialGraphClient {
202
210
  * {@link MaterialGraphClient.browse} to walk by lightness/vividness, and
203
211
  * {@link MaterialGraphClient.search} when colour is one signal among many.
204
212
  * Report matches as visual likeness, never a stated colour specification.
205
- * `family` gates the answer to ONE colorscope colour family when nearness
206
- * alone crosses a hue boundary; a gated page can be shorter than `limit`.
213
+ * `family` gates to ONE colorscope family (a gated page can be short); `brand` gates to named brands — one brand's nearest colour to another's.
207
214
  */
208
215
  byColour(params, signal) {
209
216
  return this.#get("search_by_colour", {
@@ -213,6 +220,7 @@ export class MaterialGraphClient {
213
220
  a: params.a,
214
221
  b: params.b,
215
222
  category: params.category,
223
+ brand: params.brand,
216
224
  family: params.family,
217
225
  limit: params.limit,
218
226
  }, signal);
@@ -307,7 +315,11 @@ export class MaterialGraphClient {
307
315
  if (ids.length > MAX_BATCH_IDS) {
308
316
  throw MaterialGraphError.unexpected(`MaterialGraphClient.materials(): at most ${MAX_BATCH_IDS} ids per call, received ${ids.length}. Split into multiple batches.`);
309
317
  }
310
- return this.#post("get_materials_batch_post", { ids, view: options?.view ?? "tile", idKind: options?.idKind ?? "variantId" }, signal);
318
+ return this.#post("get_materials_batch_post", {
319
+ ids,
320
+ view: options?.view ?? "tile",
321
+ idKind: options?.idKind ?? "variantId",
322
+ }, signal);
311
323
  }
312
324
  materialsCached(ids, options, signal) {
313
325
  if (ids.length > MAX_BATCH_IDS) {
@@ -388,38 +400,38 @@ export class MaterialGraphClient {
388
400
  searchVocabulary(query, options, signal) {
389
401
  return searchVocabularyRequest(this.#transport, query, options, signal);
390
402
  }
391
- /** "I have words — what do they mean in YOUR vocabulary?" — see {@link resolvePhrasesRequest}. */
392
- resolve(phrases, signal) {
393
- return resolvePhrasesRequest(this.#transport, phrases, signal);
403
+ /** "I have words — what do they mean, and what stands behind them?" — see {@link resolvePhrases}. */
404
+ resolve(phrases, scope) {
405
+ return resolvePhrases(this.#transport, phrases, scope);
394
406
  }
395
407
  /**
396
408
  * THE QUESTION: "which materials, from ANYONE, are ASTM E84 Class A AND
397
- * GREENGUARD-certified?" — hard specifications, no ranking, every brand at once
398
- * whatever each called the value in source.
409
+ * GREENGUARD-certified?" — hard specifications, no ranking, every brand at
410
+ * once whatever each called the value in source. The value-anchored,
411
+ * spec-only primitive (GET `/api/v1/search/specifications`).
399
412
  *
400
413
  * Instead use `search({ filters })` when the spec merely GATES a ranked
401
414
  * question, `codes.advisory()` when the real question is what a space demands,
402
- * and {@link MaterialGraphClient.specificationFacets} first if you do not know
403
- * the canonical tokens. Guessing one no longer returns a silently empty
404
- * answer: an unresolvable criterion means the query NEVER RAN, and the
405
- * response says so — read {@link BySpecResponse.unresolvedCriteria} before
406
- * treating an empty `matches` as "nothing qualifies".
407
- *
408
- * A PAGE, NOT A CORPUS, unusually so: `totalMatches` is the size of the
409
- * returned sample under `limit`, not a corpus count. See {@link BySpecResponse}.
410
- *
411
- * Find materials by canonical specification across every brand — the
412
- * value-anchored, spec-only primitive (GET `/api/v1/search/specifications`).
413
- * Each `spec` is an `attribute:value` criterion; a variant must satisfy ALL of
414
- * them, and each match carries per-criterion trust (`confidence` +
415
- * `sourceBasis`). Answers "which materials, from anyone, are ASTM E84 Class A
416
- * AND GREENGUARD-certified?" Build the strings with `specCriterion` /
417
- * `certificationProgrammeCriterion` (e.g.
418
- * `certificationProgrammeCriterion("GREENGUARD Gold")`); discover the queryable
419
- * vocabulary with {@link specificationFacets} and {@link attributeValues}.
415
+ * and {@link specificationFacets} first if you do not know the canonical
416
+ * tokens. Guessing one no longer returns a silently empty answer: an
417
+ * unresolvable criterion means the query NEVER RAN and the response says so —
418
+ * read {@link BySpecResponse.unresolvedCriteria} before treating an empty
419
+ * `matches` as "nothing qualifies". A PAGE, NOT A CORPUS, unusually so:
420
+ * `totalMatches` is the returned sample's size under `limit`, not a corpus
421
+ * count. Each `spec` is an `attribute:value` criterion built with
422
+ * `specCriterion` / `certificationProgrammeCriterion`; a variant must satisfy
423
+ * ALL of them, and each match carries per-criterion trust (`confidence` +
424
+ * `sourceBasis`). NUMBER-valued specs take BOUNDS, not values — pass
425
+ * `options.ranges` built with `specRangeCriterion("light_reflectance_value",
426
+ * { min: 60 })`; bounds are inclusive, at least one is required, and a
427
+ * non-numeric stored value is excluded rather than read as zero.
420
428
  */
421
429
  bySpec(spec, options, signal) {
422
- return this.#get("catalog_by_spec", { spec: Array.isArray(spec) ? spec : [spec], limit: options?.limit }, signal);
430
+ return this.#get("catalog_by_spec", {
431
+ spec: Array.isArray(spec) ? spec : [spec],
432
+ ...(options?.ranges?.length ? { specRange: options.ranges } : {}),
433
+ limit: options?.limit,
434
+ }, signal);
423
435
  }
424
436
  // ── Taxonomy + meta ──────────────────────────────────────────────────────────
425
437
  /**
@@ -575,26 +587,17 @@ export class MaterialGraphClient {
575
587
  return this.#get("explain_material_term", undefined, signal, { term });
576
588
  }
577
589
  // ── Brands ───────────────────────────────────────────────────────────────────
578
- /**
579
- * THE QUESTION: "whose catalogs are in here, and how big is each?" The SDK's
580
- * one OFFSET-paginated surface: read `meta.total` for the collection size and
581
- * `meta.hasMore` to keep walking. Instead use {@link MaterialGraphClient.brand}
582
- * for one brand's profile and trade signals.
583
- */
590
+ /** "Whose catalogs are in here, and how big is each?" — {@link listBrands}. */
584
591
  brands(options = {}, signal) {
585
- return this.#get("list_brands", { q: options.q, limit: options.limit, offset: options.offset }, signal);
592
+ return listBrands(this.#transport, options, signal);
586
593
  }
587
- /**
588
- * THE QUESTION: "who is this brand, and what do they claim?" — profile plus the
589
- * trade/spec signals ON RECORD. `trade` is keyed by attribute id and an ABSENT
590
- * key means no value is held — never infer `false`. Instead use
591
- * {@link MaterialGraphClient.search} to find their materials; this describes the
592
- * company, not the catalog.
593
- */
594
+ /** "Who is this brand, and what do they claim?" — {@link brandProfile}. */
594
595
  brand(brandSlug, options, signal) {
595
- return this.#get("get_brand_profile", { q: options?.q }, signal, {
596
- brandSlug,
597
- });
596
+ return brandProfile(this.#transport, brandSlug, options, signal);
597
+ }
598
+ /** "This maker's whole range, the way a fan deck reads" — {@link brandColourDeck}. */
599
+ brandColourDeck(brandSlug, signal) {
600
+ return brandColourDeck(this.#transport, brandSlug, signal);
598
601
  }
599
602
  // ── Runs (the hosted agentic surface) ────────────────────────────────────────
600
603
  /**
package/errors.js CHANGED
@@ -53,7 +53,9 @@ function sanitizeRecord(value, secrets, depth) {
53
53
  .slice(0, MAX_EXTENSION_ITEMS)
54
54
  .flatMap(([key, item]) => {
55
55
  const sanitized = sanitizeValue(item, secrets, depth + 1);
56
- return sanitized === undefined ? [] : [[safeString(key, secrets), sanitized]];
56
+ return sanitized === undefined
57
+ ? []
58
+ : [[safeString(key, secrets), sanitized]];
57
59
  });
58
60
  return Object.fromEntries(entries);
59
61
  }
@@ -66,10 +68,13 @@ function sanitizeIssues(value, secrets) {
66
68
  .map((issue) => sanitizeRecord(issue, secrets, 0));
67
69
  }
68
70
  function sanitizeSuggestions(value, secrets) {
69
- if (!Array.isArray(value) || value.some((suggestion) => typeof suggestion !== "string")) {
71
+ if (!Array.isArray(value) ||
72
+ value.some((suggestion) => typeof suggestion !== "string")) {
70
73
  return undefined;
71
74
  }
72
- return value.slice(0, MAX_EXTENSION_ITEMS).map((suggestion) => safeString(suggestion, secrets));
75
+ return value
76
+ .slice(0, MAX_EXTENSION_ITEMS)
77
+ .map((suggestion) => safeString(suggestion, secrets));
73
78
  }
74
79
  function normalizeProblem(body, secrets) {
75
80
  if (!isRecord(body) ||
@@ -89,11 +94,15 @@ function normalizeProblem(body, secrets) {
89
94
  title: safeString(body.title, secrets),
90
95
  status: body.status,
91
96
  detail: safeString(body.detail, secrets),
92
- ...(body.instance === undefined ? {} : { instance: safeString(body.instance, secrets) }),
97
+ ...(body.instance === undefined
98
+ ? {}
99
+ : { instance: safeString(body.instance, secrets) }),
93
100
  is_retriable: body.is_retriable,
94
101
  trace_id: safeString(body.trace_id, secrets),
95
102
  ...(suggestions === undefined ? {} : { suggestions }),
96
- ...(typeof body.doc_uri === "string" ? { doc_uri: safeString(body.doc_uri, secrets) } : {}),
103
+ ...(typeof body.doc_uri === "string"
104
+ ? { doc_uri: safeString(body.doc_uri, secrets) }
105
+ : {}),
97
106
  ...(issues === undefined ? {} : { issues }),
98
107
  };
99
108
  }
@@ -107,7 +116,9 @@ function normalizeSimple(body, secrets) {
107
116
  };
108
117
  }
109
118
  function unexpectedMessage(status, responseText, secrets) {
110
- const sanitized = redact(responseText, secrets).trim().slice(0, MAX_UNEXPECTED_TEXT);
119
+ const sanitized = redact(responseText, secrets)
120
+ .trim()
121
+ .slice(0, MAX_UNEXPECTED_TEXT);
111
122
  return sanitized.length > 0
112
123
  ? `MaterialGraph request failed with HTTP ${status}: ${sanitized}`
113
124
  : `MaterialGraph request failed with HTTP ${status}`;
@@ -143,7 +154,10 @@ export class MaterialGraphError extends Error {
143
154
  return this.retriable;
144
155
  }
145
156
  static unexpected(message) {
146
- return new MaterialGraphError(message, { kind: "unexpected", retriable: false });
157
+ return new MaterialGraphError(message, {
158
+ kind: "unexpected",
159
+ retriable: false,
160
+ });
147
161
  }
148
162
  static fromTransport(input) {
149
163
  if (input.callerSignal?.aborted === true) {
@@ -174,7 +188,9 @@ export class MaterialGraphError extends Error {
174
188
  const retriable = RETRIABLE_STATUS.has(input.status);
175
189
  const metadata = requestMetadata(input);
176
190
  if (input.status === 429) {
177
- return new MaterialGraphError(problem?.detail ?? simple?.error ?? "MaterialGraph rate limit exceeded (429)", {
191
+ return new MaterialGraphError(problem?.detail ??
192
+ simple?.error ??
193
+ "MaterialGraph rate limit exceeded (429)", {
178
194
  ...metadata,
179
195
  status: input.status,
180
196
  rateLimit: input.rateLimit,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Wire types for a maker's whole range — GET `/api/v1/brands/{brandSlug}/colours`.
3
+ *
4
+ * The one read in the SDK that is deliberately UNPAGINATED. Family order is only
5
+ * correct over a complete range: the greys cannot be placed against the browns
6
+ * while holding one page of each. So the response carries the brand, and
7
+ * `truncatedAt` appears only if a range ever exceeds the server's ceiling.
8
+ */
9
+ export interface FanDeckSwatch {
10
+ /** Opaque token. Never parse or construct one. */
11
+ variantId: string;
12
+ brand: string;
13
+ /** The maker's own colour name. Null when the catalogue publishes none. */
14
+ name: string | null;
15
+ /** The maker's PUBLISHED hex. This read never returns a derived colour. */
16
+ hex: string;
17
+ /** colorscope's family id, lower case (`cream`, `gray`, `blue`). */
18
+ family: string;
19
+ /** OKLab L, 0–1. The sort key within a family: light to dark. */
20
+ lightness: number;
21
+ imageUrl: string | null;
22
+ }
23
+ export interface FanDeckFamily {
24
+ family: string;
25
+ /** Count over the WHOLE range, computed before any truncation. */
26
+ count: number;
27
+ }
28
+ export interface BrandFanDeck {
29
+ /** The brands the deck covers, as resolved from the slug. */
30
+ brands: string[];
31
+ /** Grouped by `families` order, light to dark within each family. */
32
+ swatches: FanDeckSwatch[];
33
+ families: FanDeckFamily[];
34
+ /**
35
+ * Present ONLY when the range exceeded the server ceiling. Absent means the
36
+ * deck is complete — absent is not zero.
37
+ */
38
+ truncatedAt?: number;
39
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Wire types for a maker's whole range — GET `/api/v1/brands/{brandSlug}/colours`.
3
+ *
4
+ * The one read in the SDK that is deliberately UNPAGINATED. Family order is only
5
+ * correct over a complete range: the greys cannot be placed against the browns
6
+ * while holding one page of each. So the response carries the brand, and
7
+ * `truncatedAt` appears only if a range ever exceeds the server's ceiling.
8
+ */
9
+ export {};