@nfcard/validation 0.24.0 → 0.25.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _nfcard_types from '@nfcard/types';
2
- import { CardAsset, CardElement, ElementTransform, ProfileLayout } from '@nfcard/types';
2
+ import { CardAsset, CardElement, ElementTransform, ProfileObjectKind, ProfileLayout } from '@nfcard/types';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -145,17 +145,17 @@ declare const textObjectStyleSchema: z.ZodObject<{
145
145
  fontKey?: string | undefined;
146
146
  italic?: boolean | undefined;
147
147
  underline?: boolean | undefined;
148
- color?: string | undefined;
149
- sizeStep?: "sm" | "md" | "lg" | undefined;
150
148
  align?: "start" | "center" | undefined;
149
+ sizeStep?: "sm" | "md" | "lg" | undefined;
150
+ color?: string | undefined;
151
151
  }, {
152
152
  weight?: "bold" | "normal" | undefined;
153
153
  fontKey?: string | undefined;
154
154
  italic?: boolean | undefined;
155
155
  underline?: boolean | undefined;
156
- color?: string | undefined;
157
- sizeStep?: "sm" | "md" | "lg" | undefined;
158
156
  align?: "start" | "center" | undefined;
157
+ sizeStep?: "sm" | "md" | "lg" | undefined;
158
+ color?: string | undefined;
159
159
  }>;
160
160
  declare const imageObjectStyleSchema: z.ZodObject<{
161
161
  crop: z.ZodOptional<z.ZodObject<{
@@ -193,16 +193,74 @@ declare const imageObjectStyleSchema: z.ZodObject<{
193
193
  borderColor?: string | undefined;
194
194
  borderPx?: number | undefined;
195
195
  }>;
196
- declare const buttonObjectStyleSchema: z.ZodObject<{
196
+ /**
197
+ * Contact rows and social chips: text minus `align` and `sizeStep`. Row
198
+ * alignment is the template's grid; and a row is a COMPOUND (label/value
199
+ * spans with their own px sizes), so no wrapper-level em can scale it
200
+ * without destroying the internal hierarchy — either would be a
201
+ * stored-but-dead field.
202
+ */
203
+ declare const rowTextStyleSchema: z.ZodObject<Omit<{
204
+ color: z.ZodOptional<z.ZodString>;
205
+ sizeStep: z.ZodOptional<z.ZodEnum<["sm", "md", "lg"]>>;
206
+ align: z.ZodOptional<z.ZodEnum<["start", "center"]>>;
207
+ weight: z.ZodOptional<z.ZodEnum<["normal", "bold"]>>;
208
+ italic: z.ZodOptional<z.ZodBoolean>;
209
+ underline: z.ZodOptional<z.ZodBoolean>;
210
+ fontKey: z.ZodOptional<z.ZodString>;
211
+ }, "align" | "sizeStep">, "strict", z.ZodTypeAny, {
212
+ weight?: "bold" | "normal" | undefined;
213
+ fontKey?: string | undefined;
214
+ italic?: boolean | undefined;
215
+ underline?: boolean | undefined;
216
+ color?: string | undefined;
217
+ }, {
218
+ weight?: "bold" | "normal" | undefined;
219
+ fontKey?: string | undefined;
220
+ italic?: boolean | undefined;
221
+ underline?: boolean | undefined;
222
+ color?: string | undefined;
223
+ }>;
224
+ /** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
225
+ * DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
226
+ * in v1 — button geometry is the template's. `variant` is GONE (0.24.0
227
+ * accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
228
+ * — the toolbar exposes no variant control and none is planned — and a
229
+ * stored-but-unwritable field is dead weight on every future vintage. No
230
+ * deployed tag ever accepted one, so nothing stored carries it. */
231
+ declare const buttonObjectStyleSchema: z.ZodObject<Pick<{
232
+ color: z.ZodOptional<z.ZodString>;
233
+ sizeStep: z.ZodOptional<z.ZodEnum<["sm", "md", "lg"]>>;
234
+ align: z.ZodOptional<z.ZodEnum<["start", "center"]>>;
235
+ weight: z.ZodOptional<z.ZodEnum<["normal", "bold"]>>;
236
+ italic: z.ZodOptional<z.ZodBoolean>;
237
+ underline: z.ZodOptional<z.ZodBoolean>;
238
+ fontKey: z.ZodOptional<z.ZodString>;
239
+ }, "weight" | "fontKey" | "italic" | "color"> & {
197
240
  bg: z.ZodOptional<z.ZodString>;
198
- variant: z.ZodOptional<z.ZodEnum<["solid", "soft", "outline"]>>;
199
241
  }, "strict", z.ZodTypeAny, {
200
- variant?: "solid" | "soft" | "outline" | undefined;
242
+ weight?: "bold" | "normal" | undefined;
243
+ fontKey?: string | undefined;
244
+ italic?: boolean | undefined;
245
+ color?: string | undefined;
201
246
  bg?: string | undefined;
202
247
  }, {
203
- variant?: "solid" | "soft" | "outline" | undefined;
248
+ weight?: "bold" | "normal" | undefined;
249
+ fontKey?: string | undefined;
250
+ italic?: boolean | undefined;
251
+ color?: string | undefined;
204
252
  bg?: string | undefined;
205
253
  }>;
254
+ /**
255
+ * Which style vocabulary each kind accepts — TOTAL over the kind vocabulary
256
+ * since the any-text wave, so "unknown kind" can only mean an id this
257
+ * schema's vintage cannot dispatch (already refused by `objectKindOf`).
258
+ *
259
+ * EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
260
+ * matrix from these shapes instead of hand-mirroring them, so a vocabulary
261
+ * change here reshapes the UI by construction.
262
+ */
263
+ declare const STYLE_SCHEMA_FOR_KIND: Record<ProfileObjectKind, z.ZodObject<z.ZodRawShape>>;
206
264
  declare const profileLayoutSchema: z.ZodEffects<z.ZodObject<{
207
265
  v: z.ZodLiteral<1>;
208
266
  sectionOrder: z.ZodOptional<z.ZodArray<z.ZodEnum<["identity", "bio", "contact", "social", "actions", "cta", "footer"]>, "many">>;
@@ -22665,4 +22723,4 @@ declare function validatePhysicalConfig(data: unknown): ValidationResult<z.infer
22665
22723
  declare function validateDigitalConfig(data: unknown): ValidationResult<z.infer<typeof digitalConfigSchema>>;
22666
22724
  declare function validateConfiguration(data: unknown): ValidationResult<z.infer<typeof configurationCreateSchema>>;
22667
22725
 
22668
- export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
22726
+ export { ADVANCE_FALLBACK_EM, CARD_H_MM, CARD_W_MM, CHIP_RADIUS_MM, CHIP_SCALE_FLOOR, type ChipAxis, EDGE_MARGIN_MM, EMPTY_TEXT_FLOOR_EM, ITALIC_OVERHANG_EM, MAX_CTA_BUTTONS, MIN_WALL_MM, NFC_COIL_DIAMETER_MM, NFC_RADIAL_CLEARANCE_MM, type PrintabilityCtx, type Pt, QR_MATRIX_CELLS, QR_MIN_SIZE_MM, QR_MODULE_MIN_MM, STYLE_SCHEMA_FOR_KIND, TEXT_LINE_FACTOR_EM, TEXT_MIN_EM_MM, type ValidationResult, type Violation, adminCardCreateSchema, bookingSchema, buttonObjectStyleSchema, cardAssetSchema, cardDesignMaterialSchema, cardDesignPatternSchema, cardDesignSchema, cardElementSchema, cardElementsSchema, cardProfileUpdateSchema, cardUpdateSchema, chipAnchorToMm, chipPlacementMaxXY, comboIdSchema, configurationCreateSchema, configurationUpdateSchema, contactDetailTogglesSchema, contactExchangeSchema, cornersOnCard, createOrderSchema, ctaButtonSchema, ctaButtonsSchema, digitalConfigSchema, digitalVisibilitySchema, elementPrintability, elementTransformSchema, entrySourceSchema, footprintOf, hubConfigSchema, hubProfilesUpdateSchema, imageObjectStyleSchema, isFiniteTransform, materialSchema, mmToNearestChipAnchor, obbCorners, patternFamilySchema, physicalConfigSchema, physicalContentOverridesSchema, profileAccessTokenCreateSchema, profileBackgroundSchema, profileCreateSchema, profileLayoutSchema, profilePinVerifySchema, profileUpdateSchema, profileUserDataSchema, profileVisibilitySchema, qrModeSchema, rowTextStyleSchema, safeProfileLayout, sanitizeHttpUrl, sanitizeText, signupWithProfileSchema, socialLinksSchema, socialOverridesSchema, textAdvanceEm, textObjectStyleSchema, tipJarSchema, userDataSchema, validateConfiguration, validateDigitalConfig, validatePhysicalConfig, validateUserData, webAddressSchema };
package/dist/index.js CHANGED
@@ -484,13 +484,16 @@ var imageObjectStyleSchema = z.object({
484
484
  borderPx: z.number().int().min(0).max(8).optional(),
485
485
  shape: z.enum(["circle", "rounded", "square"]).optional()
486
486
  }).strict();
487
- var buttonObjectStyleSchema = z.object({
488
- bg: z.string().regex(HEX_RE).optional(),
489
- variant: z.enum(["solid", "soft", "outline"]).optional()
490
- }).strict();
487
+ var rowTextStyleSchema = textObjectStyleSchema.omit({
488
+ align: true,
489
+ sizeStep: true
490
+ });
491
+ var buttonObjectStyleSchema = textObjectStyleSchema.pick({ color: true, weight: true, italic: true, fontKey: true }).extend({ bg: z.string().regex(HEX_RE).optional() }).strict();
491
492
  var STYLE_SCHEMA_FOR_KIND = {
492
493
  text: textObjectStyleSchema,
493
494
  freeText: textObjectStyleSchema,
495
+ contactRow: rowTextStyleSchema,
496
+ socialLink: rowTextStyleSchema,
494
497
  image: imageObjectStyleSchema,
495
498
  ctaButton: buttonObjectStyleSchema,
496
499
  linkOut: buttonObjectStyleSchema,
@@ -562,7 +565,7 @@ var profileLayoutSchema = z.object({
562
565
  ctx.addIssue({
563
566
  code: z.ZodIssueCode.custom,
564
567
  path: ["objects", key, "style"],
565
- message: `kind '${kind}' is not stylable`
568
+ message: `kind '${kind}' has no style vocabulary in this build`
566
569
  });
567
570
  continue;
568
571
  }
@@ -1186,6 +1189,7 @@ export {
1186
1189
  QR_MATRIX_CELLS,
1187
1190
  QR_MIN_SIZE_MM,
1188
1191
  QR_MODULE_MIN_MM,
1192
+ STYLE_SCHEMA_FOR_KIND,
1189
1193
  TEXT_LINE_FACTOR_EM,
1190
1194
  TEXT_MIN_EM_MM,
1191
1195
  adminCardCreateSchema,
@@ -1235,6 +1239,7 @@ export {
1235
1239
  profileUserDataSchema,
1236
1240
  profileVisibilitySchema,
1237
1241
  qrModeSchema,
1242
+ rowTextStyleSchema,
1238
1243
  safeProfileLayout,
1239
1244
  sanitizeHttpUrl,
1240
1245
  sanitizeText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nfcard/validation",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^3.24.0",
20
- "@nfcard/types": "0.18.0"
20
+ "@nfcard/types": "0.19.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsup": "^8.0.0"
@@ -7,9 +7,9 @@
7
7
  * strings spelled out.
8
8
  */
9
9
  import { describe, expect, it } from 'vitest'
10
- import { CANONICAL_OBJECT_ORDER, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
10
+ import { CANONICAL_OBJECT_ORDER, PROFILE_OBJECT_KINDS, ctaObjectIndex, type ProfileLayout } from '@nfcard/types'
11
11
  import { MAX_CTA_BUTTONS, digitalConfigSchema } from './index'
12
- import { profileLayoutSchema, safeProfileLayout } from './profileLayout'
12
+ import { STYLE_SCHEMA_FOR_KIND, profileLayoutSchema, safeProfileLayout } from './profileLayout'
13
13
 
14
14
  const okLayout: ProfileLayout = {
15
15
  v: 1,
@@ -21,8 +21,8 @@ const okLayout: ProfileLayout = {
21
21
  objects: {
22
22
  name: { style: { color: '#112233', sizeStep: 'lg', weight: 'bold' } },
23
23
  avatar: { style: { crop: { x: 0.5, y: 0.25, zoom: 1.6 }, borderColor: '#ffffff', borderPx: 3 } },
24
- 'cta-0': { style: { bg: '#16a34a', variant: 'solid' } },
25
- booking: { style: { variant: 'outline' } },
24
+ 'cta-0': { style: { bg: '#16a34a', color: '#ffffff' } },
25
+ booking: { style: { weight: 'bold' } },
26
26
  },
27
27
  ownedObjects: [{ id: 'ft-abcd', kind: 'freeText', section: 'bio', text: 'Hello there' }],
28
28
  }
@@ -69,10 +69,10 @@ describe('profileLayoutSchema — rejects abuse', () => {
69
69
  expect(bad({ v: 1, objectOrder: { unknownSection: ['social-x'] } })).toBe(false)
70
70
  })
71
71
 
72
- it('objects: unknown ids, non-stylable kinds, injection-shaped values', () => {
72
+ it('objects: unknown ids, off-vocabulary props, injection-shaped values', () => {
73
73
  expect(bad({ v: 1, objects: { 'not-a-thing': { style: {} } } })).toBe(false)
74
- // contactRow is arrangeable but not stylable in v1 storing a style the
75
- // render never reads is the defect, not a feature.
74
+ // contactRow is TEXT-stylable since the any-text wave, but `bg` never
75
+ // joined its vocabulary a row has no fill of its own.
76
76
  expect(bad({ v: 1, objects: { 'contact-email': { style: { bg: '#112233' } } } })).toBe(false)
77
77
  expect(bad({ v: 1, objects: { name: { style: { color: 'red;}{' } } } })).toBe(false)
78
78
  expect(bad({ v: 1, objects: { name: { style: { color: '#12345' } } } })).toBe(false)
@@ -116,6 +116,107 @@ describe('profileLayoutSchema — rejects abuse', () => {
116
116
  })
117
117
  })
118
118
 
119
+ describe('the any-text wave: per-kind style vocabulary (kind × prop matrix)', () => {
120
+ const style = (id: string, s: Record<string, unknown>) =>
121
+ profileLayoutSchema.safeParse({ v: 1, objects: { [id]: { style: s } } }).success
122
+
123
+ it('rows (contactRow / socialLink) take the text vocabulary minus align/sizeStep', () => {
124
+ const full = {
125
+ color: '#112233',
126
+ weight: 'bold',
127
+ italic: true,
128
+ underline: true,
129
+ fontKey: 'inter',
130
+ }
131
+ expect(style('contact-email', full)).toBe(true)
132
+ expect(style('social-instagram', full)).toBe(true)
133
+ // Row alignment is the template's grid — align stays refused. And a row
134
+ // is a compound (label/value spans with own px sizes), so sizeStep has
135
+ // nothing honest to render — refused rather than stored dead.
136
+ expect(style('contact-email', { align: 'center' })).toBe(false)
137
+ expect(style('social-instagram', { align: 'start' })).toBe(false)
138
+ expect(style('contact-email', { sizeStep: 'sm' })).toBe(false)
139
+ expect(style('social-instagram', { sizeStep: 'lg' })).toBe(false)
140
+ })
141
+
142
+ it('buttons take label styling; geometry props stay out', () => {
143
+ const label = { color: '#112233', weight: 'bold', italic: true, fontKey: 'inter' }
144
+ expect(style('cta-0', { ...label, bg: '#16a34a' })).toBe(true)
145
+ expect(style('booking', { ...label, bg: '#16a34a' })).toBe(true)
146
+ // No sizeStep (button geometry is the template's), no underline, no align.
147
+ expect(style('cta-0', { sizeStep: 'lg' })).toBe(false)
148
+ expect(style('cta-0', { underline: true })).toBe(false)
149
+ expect(style('booking', { align: 'center' })).toBe(false)
150
+ })
151
+
152
+ it('🔴 variant is GONE for every button kind — no surface could ever write it', () => {
153
+ expect(style('act-share', { color: '#112233', bg: '#16a34a', weight: 'bold' })).toBe(true)
154
+ for (const id of ['cta-0', 'booking', 'act-share']) {
155
+ expect(style(id, { variant: 'solid' })).toBe(false)
156
+ }
157
+ })
158
+
159
+ it('🔴 the vocabulary is TOTAL: every kind dispatches to a schema', () => {
160
+ // Driven off PROFILE_OBJECT_KINDS, not a hardcoded list — a NINTH kind
161
+ // added to types without a row here fails THIS line, not a production
162
+ // render. (tsc would catch it too, but tsc does not run on the publish
163
+ // path — validation 0.24.0 shipped while tsc was red.)
164
+ for (const kind of PROFILE_OBJECT_KINDS) {
165
+ expect(STYLE_SCHEMA_FOR_KIND[kind], `no style schema for kind '${kind}'`).toBeDefined()
166
+ }
167
+ // And through the real dispatch: one id per kind; `{}` is the least
168
+ // style. (The empty-style accept is also the additive guarantee: old
169
+ // documents keep parsing as vocabularies widen.)
170
+ const representative: Record<string, string> = {
171
+ text: 'name',
172
+ freeText: 'ft-abcd',
173
+ contactRow: 'contact-email',
174
+ socialLink: 'social-instagram',
175
+ image: 'avatar',
176
+ ctaButton: 'cta-0',
177
+ linkOut: 'booking',
178
+ action: 'act-share',
179
+ }
180
+ for (const kind of PROFILE_OBJECT_KINDS) {
181
+ const id = representative[kind]
182
+ expect(id, `no representative id for kind '${kind}' — extend this test`).toBeDefined()
183
+ expect(style(id, {})).toBe(true)
184
+ }
185
+ })
186
+
187
+ it('🔴 a kind the table misses is a REFUSAL, never a throw', () => {
188
+ // The cross-package skew case: a types vintage that knows a kind this
189
+ // validation build does not. The guard must add an issue — an exception
190
+ // in superRefine ESCAPES safeParse (zod 3.25), and safeProfileLayout's
191
+ // "the public page never 500s over decoration" contract dies with it.
192
+ const stolen = STYLE_SCHEMA_FOR_KIND.action
193
+ delete (STYLE_SCHEMA_FOR_KIND as Partial<typeof STYLE_SCHEMA_FOR_KIND>).action
194
+ try {
195
+ const r = profileLayoutSchema.safeParse({
196
+ v: 1,
197
+ objects: { 'act-share': { style: { weight: 'bold' } } },
198
+ })
199
+ expect(r.success).toBe(false)
200
+ // A style-LESS override on the unknown kind keeps the old accept.
201
+ expect(
202
+ profileLayoutSchema.safeParse({ v: 1, objects: { 'act-share': {} } }).success,
203
+ ).toBe(true)
204
+ } finally {
205
+ STYLE_SCHEMA_FOR_KIND.action = stolen
206
+ }
207
+ })
208
+
209
+ it('the distinct-font cap counts row and button fontKeys too', () => {
210
+ const fonts = Object.fromEntries(
211
+ ['name', 'contact-email', 'social-instagram', 'cta-0', 'booking'].map((id, i) => [
212
+ id,
213
+ { style: { fontKey: `font-${i}` } },
214
+ ]),
215
+ )
216
+ expect(profileLayoutSchema.safeParse({ v: 1, objects: fonts }).success).toBe(false)
217
+ })
218
+ })
219
+
119
220
  describe('digitalConfigSchema integration', () => {
120
221
  const baseConfig = {
121
222
  templateId: 'classic',
@@ -83,22 +83,47 @@ export const imageObjectStyleSchema = z
83
83
  })
84
84
  .strict()
85
85
 
86
- export const buttonObjectStyleSchema = z
87
- .object({
88
- bg: z.string().regex(HEX_RE).optional(),
89
- variant: z.enum(['solid', 'soft', 'outline']).optional(),
90
- })
86
+ /**
87
+ * Contact rows and social chips: text minus `align` and `sizeStep`. Row
88
+ * alignment is the template's grid; and a row is a COMPOUND (label/value
89
+ * spans with their own px sizes), so no wrapper-level em can scale it
90
+ * without destroying the internal hierarchy — either would be a
91
+ * stored-but-dead field.
92
+ */
93
+ export const rowTextStyleSchema = textObjectStyleSchema.omit({
94
+ align: true,
95
+ sizeStep: true,
96
+ })
97
+
98
+ /** Label styling (the text vocabulary's `color`/`weight`/`italic`/`fontKey`,
99
+ * DERIVED so the validators stay in lockstep) + `bg` fill. No `sizeStep`
100
+ * in v1 — button geometry is the template's. `variant` is GONE (0.24.0
101
+ * accepted it and the render did style it): NO SURFACE COULD EVER WRITE IT
102
+ * — the toolbar exposes no variant control and none is planned — and a
103
+ * stored-but-unwritable field is dead weight on every future vintage. No
104
+ * deployed tag ever accepted one, so nothing stored carries it. */
105
+ export const buttonObjectStyleSchema = textObjectStyleSchema
106
+ .pick({ color: true, weight: true, italic: true, fontKey: true })
107
+ .extend({ bg: z.string().regex(HEX_RE).optional() })
91
108
  .strict()
92
109
 
93
110
  /**
94
- * Which style vocabulary each kind accepts. `contactRow` and `socialLink`
95
- * are deliberately ABSENT in v1: no template renders a style for them yet,
96
- * and a schema that stores what the server never reads is the EditTextSheet
97
- * lesson in write form. Additive to extend.
111
+ * Which style vocabulary each kind accepts TOTAL over the kind vocabulary
112
+ * since the any-text wave, so "unknown kind" can only mean an id this
113
+ * schema's vintage cannot dispatch (already refused by `objectKindOf`).
114
+ *
115
+ * EXPORTED for the web toolbar: `toolbarControlsFor` derives its control
116
+ * matrix from these shapes instead of hand-mirroring them, so a vocabulary
117
+ * change here reshapes the UI by construction.
98
118
  */
99
- const STYLE_SCHEMA_FOR_KIND: Partial<Record<ProfileObjectKind, z.ZodTypeAny>> = {
119
+ export const STYLE_SCHEMA_FOR_KIND: Record<
120
+ ProfileObjectKind,
121
+ z.ZodObject<z.ZodRawShape>
122
+ > = {
100
123
  text: textObjectStyleSchema,
101
124
  freeText: textObjectStyleSchema,
125
+ contactRow: rowTextStyleSchema,
126
+ socialLink: rowTextStyleSchema,
102
127
  image: imageObjectStyleSchema,
103
128
  ctaButton: buttonObjectStyleSchema,
104
129
  linkOut: buttonObjectStyleSchema,
@@ -191,11 +216,18 @@ export const profileLayoutSchema = z
191
216
  }
192
217
  const style = overrideParsed.data.style
193
218
  if (style === undefined) continue
219
+ // The table is TOTAL by type, but tsc does not run on the publish
220
+ // path and `objectKindOf` lives in @nfcard/types behind a ^range —
221
+ // a types vintage that knows a NINTH kind can resolve under a
222
+ // validation build whose table stops at eight. Without this guard
223
+ // that skew is a TypeError that ESCAPES safeParse (verified against
224
+ // zod 3.25), turning `safeProfileLayout`'s promised null into a 500
225
+ // on the public render. Refusal, never a throw.
194
226
  if (!styleSchema) {
195
227
  ctx.addIssue({
196
228
  code: z.ZodIssueCode.custom,
197
229
  path: ['objects', key, 'style'],
198
- message: `kind '${kind}' is not stylable`,
230
+ message: `kind '${kind}' has no style vocabulary in this build`,
199
231
  })
200
232
  continue
201
233
  }