@datatechsolutions/tympan-tokens 0.4.1 → 0.5.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.
Files changed (91) hide show
  1. package/THIRD_PARTY_NOTICES.md +5 -5
  2. package/dist/contrast-report.md +95 -95
  3. package/dist/dtcg/astrlabe.dark-high.tokens.json +99 -43
  4. package/dist/dtcg/astrlabe.dark.tokens.json +137 -81
  5. package/dist/dtcg/astrlabe.light-high.tokens.json +146 -33
  6. package/dist/dtcg/astrlabe.light.tokens.json +211 -98
  7. package/dist/fonts.css +80 -80
  8. package/dist/index.d.ts +257 -58
  9. package/dist/index.js +568 -36
  10. package/dist/index.js.map +1 -1
  11. package/dist/print-themes/print-aquarela.css +7 -7
  12. package/dist/print-themes/print-art-nouveau.css +2 -2
  13. package/dist/print-themes/print-atlas-oficial.css +7 -7
  14. package/dist/print-themes/print-azulejo-modernista.css +2 -2
  15. package/dist/print-themes/print-bauhaus.css +2 -2
  16. package/dist/print-themes/print-blocos-coloridos.css +5 -5
  17. package/dist/print-themes/print-brutalista.css +8 -8
  18. package/dist/print-themes/print-caderno.css +14 -14
  19. package/dist/print-themes/print-cartao-postal.css +5 -5
  20. package/dist/print-themes/print-cientifico.css +3 -3
  21. package/dist/print-themes/print-concretismo.css +8 -8
  22. package/dist/print-themes/print-construtivismo.css +6 -6
  23. package/dist/print-themes/print-cordel.css +6 -6
  24. package/dist/print-themes/print-dados-br.css +12 -12
  25. package/dist/print-themes/print-dashboard.css +18 -18
  26. package/dist/print-themes/print-diagrama-modernista.css +3 -3
  27. package/dist/print-themes/print-divulgacao.css +3 -3
  28. package/dist/print-themes/print-fluxo-historico.css +3 -3
  29. package/dist/print-themes/print-grade-holandesa.css +2 -2
  30. package/dist/print-themes/print-graficos-1900.css +6 -6
  31. package/dist/print-themes/print-infografico-ilustrado.css +6 -6
  32. package/dist/print-themes/print-isotype.css +2 -2
  33. package/dist/print-themes/print-jornal-1959.css +5 -5
  34. package/dist/print-themes/print-jornal.css +6 -6
  35. package/dist/print-themes/print-mapa-de-metro.css +1 -1
  36. package/dist/print-themes/print-memphis.css +5 -5
  37. package/dist/print-themes/print-minimo-de-tinta.css +4 -4
  38. package/dist/print-themes/print-papel-recortado.css +2 -2
  39. package/dist/print-themes/print-papel-salmao.css +6 -6
  40. package/dist/print-themes/print-pictogramas.css +2 -2
  41. package/dist/print-themes/print-pop-art.css +2 -2
  42. package/dist/print-themes/print-prancheta-clara.css +5 -5
  43. package/dist/print-themes/print-prancheta.css +5 -5
  44. package/dist/print-themes/print-proporcao-modular.css +2 -2
  45. package/dist/print-themes/print-riso.css +4 -4
  46. package/dist/print-themes/print-semanario.css +13 -13
  47. package/dist/print-themes/print-sinalizacao.css +8 -8
  48. package/dist/print-themes/print-suico.css +4 -4
  49. package/dist/print-themes/print-tropicalia.css +3 -3
  50. package/dist/print-themes.css +133 -133
  51. package/dist/tokens.css +247 -124
  52. package/dist/tokens.json +124 -66
  53. package/dist/values.js +124 -66
  54. package/fonts/ibmplexsans/ibmplexsans-italic-cyrillic-400_500_600.woff2 +0 -0
  55. package/fonts/ibmplexsans/ibmplexsans-italic-cyrillic-ext-400_500_600.woff2 +0 -0
  56. package/fonts/ibmplexsans/ibmplexsans-italic-greek-400_500_600.woff2 +0 -0
  57. package/fonts/ibmplexsans/ibmplexsans-italic-latin-400_500_600.woff2 +0 -0
  58. package/fonts/ibmplexsans/ibmplexsans-italic-latin-ext-400_500_600.woff2 +0 -0
  59. package/fonts/ibmplexsans/ibmplexsans-italic-vietnamese-400_500_600.woff2 +0 -0
  60. package/fonts/manifest.json +998 -820
  61. package/fonts/nunitosans/nunitosans-normal-cyrillic-400_600_700_800.woff2 +0 -0
  62. package/fonts/nunitosans/nunitosans-normal-cyrillic-ext-400_600_700_800.woff2 +0 -0
  63. package/fonts/nunitosans/nunitosans-normal-latin-400_600_700_800.woff2 +0 -0
  64. package/fonts/nunitosans/nunitosans-normal-latin-ext-400_600_700_800.woff2 +0 -0
  65. package/fonts/nunitosans/nunitosans-normal-vietnamese-400_600_700_800.woff2 +0 -0
  66. package/fonts/sourceserif4/sourceserif4-italic-cyrillic-400_600.woff2 +0 -0
  67. package/fonts/sourceserif4/sourceserif4-italic-cyrillic-ext-400_600.woff2 +0 -0
  68. package/fonts/sourceserif4/sourceserif4-italic-greek-400_600.woff2 +0 -0
  69. package/fonts/sourceserif4/sourceserif4-italic-latin-400_600.woff2 +0 -0
  70. package/fonts/sourceserif4/sourceserif4-italic-latin-ext-400_600.woff2 +0 -0
  71. package/fonts/sourceserif4/sourceserif4-italic-vietnamese-400_600.woff2 +0 -0
  72. package/package.json +1 -1
  73. package/src/fonts.ts +2 -2
  74. package/src/index.ts +1 -0
  75. package/src/presets.ts +115 -16
  76. package/src/print-book-themes.ts +239 -0
  77. package/src/print-presets.ts +220 -10
  78. package/src/stylesheet.ts +5 -0
  79. package/src/theme.ts +35 -5
  80. /package/fonts/ibmplexsans/{ibmplexsans-italic-cyrillic.woff2 → ibmplexsans-italic-cyrillic-400.woff2} +0 -0
  81. /package/fonts/ibmplexsans/{ibmplexsans-italic-cyrillic-ext.woff2 → ibmplexsans-italic-cyrillic-ext-400.woff2} +0 -0
  82. /package/fonts/ibmplexsans/{ibmplexsans-italic-greek.woff2 → ibmplexsans-italic-greek-400.woff2} +0 -0
  83. /package/fonts/ibmplexsans/{ibmplexsans-italic-latin.woff2 → ibmplexsans-italic-latin-400.woff2} +0 -0
  84. /package/fonts/ibmplexsans/{ibmplexsans-italic-latin-ext.woff2 → ibmplexsans-italic-latin-ext-400.woff2} +0 -0
  85. /package/fonts/ibmplexsans/{ibmplexsans-italic-vietnamese.woff2 → ibmplexsans-italic-vietnamese-400.woff2} +0 -0
  86. /package/fonts/sourceserif4/{sourceserif4-italic-cyrillic.woff2 → sourceserif4-italic-cyrillic-400.woff2} +0 -0
  87. /package/fonts/sourceserif4/{sourceserif4-italic-cyrillic-ext.woff2 → sourceserif4-italic-cyrillic-ext-400.woff2} +0 -0
  88. /package/fonts/sourceserif4/{sourceserif4-italic-greek.woff2 → sourceserif4-italic-greek-400.woff2} +0 -0
  89. /package/fonts/sourceserif4/{sourceserif4-italic-latin.woff2 → sourceserif4-italic-latin-400.woff2} +0 -0
  90. /package/fonts/sourceserif4/{sourceserif4-italic-latin-ext.woff2 → sourceserif4-italic-latin-ext-400.woff2} +0 -0
  91. /package/fonts/sourceserif4/{sourceserif4-italic-vietnamese.woff2 → sourceserif4-italic-vietnamese-400.woff2} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datatechsolutions/tympan-tokens",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Tympan design tokens: W3C DTCG source (colour, space, radius, type, elevation and glass, motion, z) and an OKLCH theme generator, with the tympan, fakhir, astrlabe, neutral and high-contrast presets in light and dark (plus opt-in UI themes derived from the print book styles), built to --ty-* CSS custom properties, JSON and a TypeScript export.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "author": "Natalia Mesquita",
package/src/fonts.ts CHANGED
@@ -16,8 +16,8 @@ import { SCRIPT_RULES } from './scripts.ts'
16
16
 
17
17
  /** Base stacks of every preset: Source Serif 4, IBM Plex Sans, IBM Plex Mono. */
18
18
  export const BASE_FONT_SPECS: readonly string[] = [
19
- 'Source Serif 4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400',
20
- 'IBM Plex Sans:ital,wght@0,400;0,500;0,600;0,700;1,400',
19
+ 'Source Serif 4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400;1,8..60,600',
20
+ 'IBM Plex Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600',
21
21
  'IBM Plex Mono:ital,wght@0,400;0,500;0,600;0,700;1,400',
22
22
  ]
23
23
 
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from './dtcg.ts'
6
6
  export * from './stylesheet.ts'
7
7
  export * from './flow.ts'
8
8
  export * from './print-presets.ts'
9
+ export * from './print-book-themes.ts'
9
10
  export * from './print-themes.ts'
10
11
  export * from './print-aliases.ts'
11
12
  export * from './fonts.ts'
package/src/presets.ts CHANGED
@@ -110,8 +110,10 @@ export const fakhirPreset: ThemeConfig = {
110
110
  /**
111
111
  * The Astrlabe workflow engine's look (`data-ty-theme="astrlabe"`): slate
112
112
  * neutrals, an indigo brand (indigo 500 as the accent family, one step darker
113
- * where text needs 4.5:1 on light grounds), translucent glass surfaces and the
114
- * rounder corners of that app (12 px controls, about 20 px cards). The call to
113
+ * where text needs 4.5:1 on light grounds), liquid-glass surfaces (gradient
114
+ * fills, specular edges, a strong saturation boost at night), the rounder
115
+ * corners of that app (12 px controls, 20 px cards, 24 px sheets), its
116
+ * compact type scale and the sky, indigo and violet mark. The call to
115
117
  * action is the indigo to purple gradient its primary buttons fill with, and
116
118
  * text (headings included) uses the platform's own system fonts. The state
117
119
  * contract holds on every ground: running is a cyan clearly off the indigo
@@ -142,22 +144,30 @@ export const astrlabePreset: ThemeConfig = {
142
144
  'on-brand': '#ffffff',
143
145
  bg: '#f8fafc',
144
146
  surface: 'rgb(255 255 255 / 0.72)',
145
- 'surface-raised': 'rgb(255 255 255 / 0.9)',
147
+ 'surface-raised': 'rgb(255 255 255 / 0.86)',
146
148
  'surface-solid': '#ffffff',
147
149
  'surface-raised-solid': '#ffffff',
148
- 'surface-sunken': '#f1f5f9',
149
- line: 'rgb(148 163 184 / 0.3)',
150
+ // The canvas plane: the app's pale lavender ground.
151
+ 'surface-sunken': '#f2f3fa',
152
+ // The liquid divider: slate 400 at about a quarter.
153
+ line: 'rgb(148 163 184 / 0.26)',
150
154
  'line-strong': 'rgb(100 116 139 / 0.45)',
151
155
  ink: '#0f172a',
152
- 'ink-2': '#334155',
153
- // One step darker than the old #546175 so AA holds on the glass grounds.
154
- 'ink-3': '#4d5a6b',
156
+ // Slate 600 for secondary text, as the app always had.
157
+ 'ink-2': '#475569',
158
+ // Slate 500 fails 4.5:1 on the sunken ground by a hair; this is the
159
+ // nearest slate that holds AA there.
160
+ 'ink-3': '#5b6779',
155
161
  success: '#15803d',
156
162
  // Cyan, deliberately off the indigo brand, for the running state.
157
163
  info: '#0e7490',
158
164
  warning: '#a16207',
159
165
  danger: '#be123c',
160
- neutral: '#4d5a6b',
166
+ neutral: '#5b6779',
167
+ backdrop: 'rgb(15 23 42 / 0.4)',
168
+ // The three soft colour fields behind the glass: violet above, sky below.
169
+ 'ambient-1': 'rgb(167 139 250 / 0.05)',
170
+ 'ambient-2': 'rgb(56 189 248 / 0.03)',
161
171
  },
162
172
  dark: {
163
173
  brand: '#818cf8',
@@ -165,28 +175,117 @@ export const astrlabePreset: ThemeConfig = {
165
175
  'brand-strong': '#a5b4fc',
166
176
  'brand-soft': 'rgb(99 102 241 / 0.2)',
167
177
  'on-brand': '#1e1b4b',
168
- bg: '#0b1220',
169
- surface: 'rgb(30 41 59 / 0.72)',
170
- 'surface-raised': 'rgb(30 41 59 / 0.92)',
178
+ // Slate 950, the app's night ground.
179
+ bg: '#020617',
180
+ surface: 'rgb(30 41 59 / 0.7)',
181
+ 'surface-raised': 'rgb(30 41 59 / 0.78)',
171
182
  'surface-solid': '#131c2f',
172
183
  'surface-raised-solid': '#1b2539',
173
184
  'surface-sunken': '#0b1222',
174
- line: 'rgb(148 163 184 / 0.22)',
185
+ line: 'rgb(148 163 184 / 0.24)',
175
186
  'line-strong': 'rgb(148 163 184 / 0.36)',
176
- ink: '#f8fafc',
187
+ ink: '#ffffff',
177
188
  'ink-2': '#cbd5e1',
178
- 'ink-3': '#a3b0c0',
189
+ 'ink-3': '#94a3b8',
179
190
  success: '#4ade80',
180
191
  info: '#22d3ee',
181
192
  warning: '#fbbf24',
182
193
  danger: '#fb7185',
183
- neutral: '#a3b0c0',
194
+ neutral: '#94a3b8',
195
+ backdrop: 'rgb(0 0 0 / 0.5)',
196
+ 'ambient-1': 'rgb(124 58 237 / 0.1)',
197
+ 'ambient-2': 'rgb(2 132 199 / 0.08)',
184
198
  },
185
199
  },
186
200
  ctaPins: {
187
201
  light: ['#4f46e5', '#7c3aed', '#9333ea'],
188
202
  dark: ['#4f46e5', '#7c3aed', '#9333ea'],
189
203
  },
204
+ // 12 px controls, 20 px cards and panels, 24 px dialogs and hero sheets.
205
+ radii: { card: 20, sheet: 24 },
206
+ // Glass: a light boost on light grounds (more would amplify the ambient
207
+ // tint into a grey-lavender wash), a strong one on dark grounds.
208
+ glassSaturate: { light: 1.15, dark: 1.5 },
209
+ // The liquid-glass elevation: a soft drop under every sheet, a specular
210
+ // inner highlight on its top edge, and the role-named elevation scale.
211
+ shadowPins: {
212
+ light: {
213
+ sheet: [
214
+ { x: 0, y: 4, blur: 16, spread: -2, color: 'rgb(0 0 0 / 0.1)' },
215
+ { x: 0, y: 12, blur: 40, spread: -8, color: 'rgb(0 0 0 / 0.06)' },
216
+ ],
217
+ 'sheet-inset': [
218
+ { x: 0, y: 1, blur: 2, spread: 0, color: 'rgb(255 255 255 / 0.7)', inset: true },
219
+ { x: 0, y: -1, blur: 2, spread: 0, color: 'rgb(0 0 0 / 0.04)', inset: true },
220
+ ],
221
+ raised: [
222
+ { x: 0, y: 4, blur: 12, spread: -2, color: 'rgb(15 23 42 / 0.08)' },
223
+ { x: 0, y: 2, blur: 4, spread: -2, color: 'rgb(15 23 42 / 0.04)' },
224
+ ],
225
+ floating: [
226
+ { x: 0, y: 12, blur: 24, spread: -8, color: 'rgb(15 23 42 / 0.12)' },
227
+ { x: 0, y: 4, blur: 8, spread: -4, color: 'rgb(15 23 42 / 0.06)' },
228
+ ],
229
+ modal: [
230
+ { x: 0, y: 40, blur: 100, spread: -30, color: 'rgb(0 0 0 / 0.45)' },
231
+ { x: 0, y: 16, blur: 40, spread: -12, color: 'rgb(0 0 0 / 0.18)' },
232
+ ],
233
+ },
234
+ dark: {
235
+ sheet: [{ x: 0, y: 12, blur: 40, spread: -12, color: 'rgb(0 0 0 / 0.5)' }],
236
+ 'sheet-inset': [{ x: 0, y: 1, blur: 1, spread: 0, color: 'rgb(255 255 255 / 0.12)', inset: true }],
237
+ raised: [
238
+ { x: 0, y: 4, blur: 12, spread: -2, color: 'rgb(0 0 0 / 0.5)' },
239
+ { x: 0, y: 2, blur: 4, spread: -2, color: 'rgb(0 0 0 / 0.3)' },
240
+ ],
241
+ floating: [
242
+ { x: 0, y: 12, blur: 24, spread: -8, color: 'rgb(0 0 0 / 0.6)' },
243
+ { x: 0, y: 4, blur: 8, spread: -4, color: 'rgb(0 0 0 / 0.4)' },
244
+ ],
245
+ modal: [
246
+ { x: 0, y: 40, blur: 100, spread: -30, color: 'rgb(0 0 0 / 0.7)' },
247
+ { x: 0, y: 16, blur: 40, spread: -12, color: 'rgb(0 0 0 / 0.5)' },
248
+ ],
249
+ },
250
+ },
251
+ modeStrings: {
252
+ light: {
253
+ // Lit from above: bright top and start edges, grey shaded ones (top end bottom start).
254
+ 'glass-edge': 'rgb(255 255 255 / 0.9) rgb(200 200 210 / 0.3) rgb(200 200 210 / 0.25) rgb(255 255 255 / 0.7)',
255
+ 'glass-edge-width': '1.5px',
256
+ // The inner highlight of raised and floating glass.
257
+ 'glass-highlight': 'inset 0 1px 2px 0 rgb(255 255 255 / 0.8), inset 0 -1px 2px 0 rgb(0 0 0 / 0.05)',
258
+ 'glass-fill': 'linear-gradient(135deg, rgb(255 255 255 / 0.8) 0%, rgb(220 225 240 / 0.62) 40%, rgb(240 245 255 / 0.68) 70%, rgb(255 255 255 / 0.8) 100%)',
259
+ 'glass-fill-raised': 'linear-gradient(135deg, rgb(255 255 255 / 0.86) 0%, rgb(215 220 238 / 0.7) 40%, rgb(235 240 255 / 0.76) 70%, rgb(255 255 255 / 0.86) 100%)',
260
+ 'mark-gradient': 'linear-gradient(135deg, #38bdf8 0%, #6366f1 40%, #a855f7 100%)',
261
+ 'wordmark-gradient': 'linear-gradient(90deg, #0369a1, #4338ca, #7e22ce)',
262
+ },
263
+ dark: {
264
+ 'glass-edge': 'rgb(255 255 255 / 0.2) rgb(255 255 255 / 0.06) rgb(255 255 255 / 0.04) rgb(255 255 255 / 0.12)',
265
+ 'glass-edge-width': '1.5px',
266
+ 'glass-highlight': 'inset 0 1px 1px 0 rgb(255 255 255 / 0.15)',
267
+ 'glass-fill': 'linear-gradient(140deg, rgb(30 41 59 / 0.7) 0%, rgb(15 23 42 / 0.6) 45%, rgb(20 30 50 / 0.55) 100%)',
268
+ 'glass-fill-raised': 'linear-gradient(140deg, rgb(30 41 59 / 0.78) 0%, rgb(15 23 42 / 0.68) 45%, rgb(20 30 50 / 0.62) 100%)',
269
+ 'mark-gradient': 'linear-gradient(135deg, #38bdf8 0%, #6366f1 40%, #a855f7 100%)',
270
+ 'wordmark-gradient': 'linear-gradient(90deg, #38bdf8, #818cf8, #a855f7)',
271
+ },
272
+ },
273
+ // The app's type scale: 30 px display, 24 px page titles, 18 px sections, 16 px
274
+ // sub-sections, 14 px labels, tight tracking on titles.
275
+ strings: {
276
+ // The display step is the 30 px wordmark of the sign-in screens.
277
+ 'font-size-display': '30px',
278
+ 'font-line-height-display': '36px',
279
+ 'font-size-h1': '24px',
280
+ 'font-line-height-h1': '32px',
281
+ 'font-size-h2': '18px',
282
+ 'font-line-height-h2': '28px',
283
+ 'font-size-h3': '16px',
284
+ 'font-line-height-h3': '24px',
285
+ 'font-size-label': '14px',
286
+ 'font-line-height-label': '20px',
287
+ 'font-tracking-h1': '-0.025em',
288
+ },
190
289
  // The platform's own UI fonts, headings included (no web font to fetch).
191
290
  fonts: {
192
291
  display: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Noto Sans', 'Arial', 'sans-serif'],
@@ -0,0 +1,239 @@
1
+ // Book themes: how a publisher or a collection defines its own look for
2
+ // @datatechsolutions/tympan-print without touching tympan's code.
3
+ //
4
+ // A theme inherits a base (one of the 39 presets or another registered theme)
5
+ // and overrides any token of PrintStyleOverrides, plus what belongs to a
6
+ // publisher rather than to a style: its marks (publisher logo, collection
7
+ // seal), the page format and bleed, the running head and folio, and local
8
+ // font files. Register it by id and pass the id to PrintBook as `estilo`.
9
+ //
10
+ // A theme may come from JSON, either in the theme's own shape
11
+ // (`PrintBookThemeJson`) or as a DTCG tree (`printThemeFromDtcg`), so a brand's
12
+ // identity can live in the brand's repository.
13
+
14
+ import { fromDtcgColor, type DtcgColor } from './dtcg.ts'
15
+ import { toHex } from './color.ts'
16
+ import { SYSTEM_FONT_FAMILIES, fontFaceCss, type FontManifest } from './fonts.ts'
17
+ import {
18
+ mergePrintStyle,
19
+ printPresetById,
20
+ resolvePrintStyleName,
21
+ type PrintCoresParciais,
22
+ type PrintPagina,
23
+ type PrintStyle,
24
+ type PrintStyleOverrides,
25
+ } from './print-presets.ts'
26
+
27
+ /** A book theme: an id, a base and overrides. */
28
+ export interface PrintBookTheme extends PrintStyleOverrides {
29
+ /** Registry id, e.g. 'lakebrasil-editorial'. Use it as PrintBook's `estilo`. */
30
+ id: string
31
+ /** A preset name (or deprecated alias) or the id of another registered theme. */
32
+ base: string
33
+ /** What the theme interprets (shown in galleries). */
34
+ referencia?: string
35
+ }
36
+
37
+ /** The JSON form of a theme: the same fields, colours as hex strings or DTCG colour objects. */
38
+ export type PrintBookThemeJson = Omit<PrintBookTheme, 'cor' | 'pb'> & {
39
+ cor?: Record<string, unknown>
40
+ pb?: Record<string, unknown>
41
+ }
42
+
43
+ const REGISTRO = new Map<string, PrintBookTheme>()
44
+
45
+ /** A style with overrides and theme extras applied (same as mergePrintStyle, kept for readability). */
46
+ export const mergePrintTheme = mergePrintStyle
47
+
48
+ /** Resolves a theme id or a preset id to a complete PrintStyle (`name` = the theme id). */
49
+ export function printThemeById(id: string, visitados: string[] = []): PrintStyle {
50
+ const tema = REGISTRO.get(id)
51
+ if (!tema) return printPresetById(id)
52
+ if (visitados.includes(id)) throw new Error(`tympan: book theme cycle ${[...visitados, id].join(' → ')}`)
53
+ const base = printThemeById(tema.base, [...visitados, id])
54
+ const { id: _id, base: _base, referencia, ...over } = tema
55
+ return { ...mergePrintTheme(base, over), name: tema.id, label: tema.label ?? base.label, referencia: referencia ?? `Tema sobre ${base.name}: ${base.referencia}` }
56
+ }
57
+
58
+ /** True when `id` names a registered theme or a preset. */
59
+ export function isPrintThemeId(id: string): boolean {
60
+ return REGISTRO.has(id) || resolvePrintStyleName(id) !== undefined
61
+ }
62
+
63
+ function normalizarCores(c: Record<string, unknown> | undefined): PrintCoresParciais | undefined {
64
+ if (!c) return undefined
65
+ const cor = (v: unknown): string => (typeof v === 'string' ? v : toHex(fromDtcgColor(v as DtcgColor)))
66
+ const out: Record<string, unknown> = {}
67
+ for (const [k, v] of Object.entries(c)) {
68
+ if ((k === 'prova' || k === 'temas' || k === 'categorias') && v && typeof v === 'object') out[k] = Object.fromEntries(Object.entries(v).map(([e, x]) => [e, cor(x)]))
69
+ else if ((k === 'ornamento' || k === 'categorica' || k === 'divergente') && Array.isArray(v)) out[k] = v.map(cor)
70
+ else out[k] = cor(v)
71
+ }
72
+ return out as PrintCoresParciais
73
+ }
74
+
75
+ /** Validates and normalises a theme read from JSON (colours may be hex or DTCG colour objects). */
76
+ export function printThemeFromJson(json: PrintBookThemeJson | string): PrintBookTheme {
77
+ const j = (typeof json === 'string' ? JSON.parse(json) : json) as PrintBookThemeJson
78
+ if (!j || typeof j.id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(j.id)) throw new Error('tympan: a book theme needs an `id` in kebab case')
79
+ if (typeof j.base !== 'string') throw new Error(`tympan: book theme "${j.id}" needs a \`base\` (a preset or a registered theme)`)
80
+ const { cor, pb, ...rest } = j
81
+ const out: PrintBookTheme = { ...rest }
82
+ const c = normalizarCores(cor)
83
+ if (c) out.cor = c
84
+ const p = normalizarCores(pb)
85
+ if (p) out.pb = p
86
+ return out
87
+ }
88
+
89
+ type No = Record<string, unknown>
90
+
91
+ /** Namespaces read for the theme's meta (id/name, base/extends): tympan's own and the short one brand repos use. */
92
+ const EXTS = ['br.com.datatechsolutions.tympan', 'dev.tympan.print'] as const
93
+
94
+ /** Unwraps a DTCG group into plain values: `{ $value }` leaves become their value; `$`-keys are dropped. */
95
+ function desembrulhar(n: unknown): unknown {
96
+ if (!n || typeof n !== 'object' || Array.isArray(n)) return n
97
+ const o = n as No
98
+ if ('$value' in o) {
99
+ const v = o.$value
100
+ if (o.$type === 'fontFamily' && Array.isArray(v)) return pilhaDeFontes(v as string[])
101
+ if (o.$type === 'color' && v && typeof v === 'object') return toHex(fromDtcgColor(v as DtcgColor))
102
+ if (o.$type === 'dimension' && v && typeof v === 'object' && 'unit' in (v as No)) return emMm(v as { value: number; unit: string })
103
+ return v
104
+ }
105
+ const out: No = {}
106
+ for (const [k, v] of Object.entries(o)) if (!k.startsWith('$')) out[k] = desembrulhar(v)
107
+ return out
108
+ }
109
+
110
+ const GENERICAS = /^(serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-[a-z-]+)$/
111
+ const pilhaDeFontes = (v: string[]) => v.map((x) => (GENERICAS.test(x) || /^["']/.test(x) ? x : `"${x}"`)).join(', ')
112
+ const emMm = (v: { value: number; unit: string }) => (v.unit === 'px' ? (v.value * 25.4) / 96 : v.unit === 'pt' ? (v.value * 25.4) / 72 : v.unit === 'cm' ? v.value * 10 : v.value)
113
+
114
+ /** Figures from the meta: an object by role, or a phrase such as "tabular-nums em numero e rotulo". */
115
+ function lerAlgarismos(v: unknown): Record<string, 'tabular' | 'proporcional'> | undefined {
116
+ if (v && typeof v === 'object') return v as Record<string, 'tabular' | 'proporcional'>
117
+ if (typeof v !== 'string') return undefined
118
+ const tipo = /proporc|proportional/.test(v) ? 'proporcional' : /tabular/.test(v) ? 'tabular' : undefined
119
+ if (!tipo) return undefined
120
+ const papeis = ['numero', 'rotulo', 'corpo'].filter((p) => v.includes(p))
121
+ return Object.fromEntries((papeis.length ? papeis : ['numero']).map((p) => [p, tipo]))
122
+ }
123
+
124
+ /**
125
+ * A theme from a DTCG (2025.10) tree. The tree mirrors PrintStyleOverrides: each group is a field
126
+ * (`label`, `fontes.<role>`, `googleFonts`, `cor.<role>`, `cor.prova.<state>`, `cor.categorica.<n>`,
127
+ * `cor.divergente.<n>`, `traco.largura`, `pb.*`, `estrutura.*`, `texto.*`, `marcas.*`, `pagina.*`, `folio.*`,
128
+ * `fontesLocais`…) and each `{ "$type", "$value" }` leaf becomes its value (colours as hex or DTCG colour
129
+ * objects, `fontFamily` arrays become CSS stacks, dimensions become mm). The theme's id and base come from
130
+ * the root `$extensions["dev.tympan.print"]` (or `["br.com.datatechsolutions.tympan"]`) as `name`/`id` and
131
+ * `extends`/`base`.
132
+ */
133
+ export function printThemeFromDtcg(tree: No | string): PrintBookTheme {
134
+ const t = (typeof tree === 'string' ? JSON.parse(tree) : tree) as No
135
+ const exts = (t.$extensions ?? {}) as No
136
+ const meta = Object.assign({}, ...EXTS.map((e) => exts[e] as No | undefined).filter(Boolean).map((m) => ({ ...(m!.tema as No | undefined), ...m }))) as No
137
+ const id = (meta.id ?? meta.name) as string | undefined
138
+ const base = (meta.base ?? meta.extends) as string | undefined
139
+ if (!id || !base) throw new Error(`tympan: a DTCG book theme needs $extensions["dev.tympan.print"] with name and extends`)
140
+ const corpo = desembrulhar(t) as No
141
+ // Ordered lists written as groups ("1", "2"… or "a", "b"…) become arrays.
142
+ const cor = corpo.cor as No | undefined
143
+ for (const g of [cor, corpo.pb as No | undefined])
144
+ if (g) for (const k of ['categorica', 'divergente', 'ornamento']) if (g[k] && typeof g[k] === 'object' && !Array.isArray(g[k])) g[k] = Object.entries(g[k] as No).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true })).map(([, v]) => v)
145
+ // Brand-repo extras in the meta: per-role weights, figures and the data palette.
146
+ const texto = { ...(corpo.texto as No | undefined) }
147
+ if (meta.pesos && typeof meta.pesos === 'object') texto.pesos = { ...(meta.pesos as No), ...(texto.pesos as No | undefined) }
148
+ const alg = lerAlgarismos(meta.algarismos)
149
+ if (alg) texto.algarismos = { ...alg, ...(texto.algarismos as No | undefined) }
150
+ if (Object.keys(texto).length) corpo.texto = texto
151
+ const dado = meta.dado as Record<string, string> | undefined
152
+ if (dado) {
153
+ const c = { ...(corpo.cor as No | undefined) }
154
+ if (dado.interesse && c.interesse === undefined) c.interesse = dado.interesse
155
+ if (dado.contexto) c.contexto = dado.contexto
156
+ if (dado.perdeu && dado.ganhou && c.divergente === undefined) c.divergente = [dado.perdeu, dado.ganhou]
157
+ corpo.cor = c
158
+ }
159
+ return printThemeFromJson({ ...corpo, id, base, ...(meta.referencia ? { referencia: meta.referencia as string } : {}) } as PrintBookThemeJson)
160
+ }
161
+
162
+ /** Registers (or replaces) a theme; accepts the theme object, its JSON or a DTCG tree. Returns the theme. */
163
+ export function registerPrintTheme(tema: PrintBookTheme | PrintBookThemeJson | No | string): PrintBookTheme {
164
+ const obj = typeof tema === 'string' ? (JSON.parse(tema) as No) : (tema as No)
165
+ const t = obj.$extensions ? printThemeFromDtcg(obj) : printThemeFromJson(obj as PrintBookThemeJson)
166
+ if (resolvePrintStyleName(t.id)) throw new Error(`tympan: book theme id "${t.id}" collides with a preset`)
167
+ REGISTRO.set(t.id, t)
168
+ printThemeById(t.id) // fails now on an unknown base or a cycle
169
+ return t
170
+ }
171
+
172
+ /** Removes a registered theme (tests, hot reload). */
173
+ export function unregisterPrintTheme(id: string): boolean {
174
+ return REGISTRO.delete(id)
175
+ }
176
+
177
+ /** Ids of the registered themes, in registration order. */
178
+ export function registeredPrintThemes(): string[] {
179
+ return [...REGISTRO.keys()]
180
+ }
181
+
182
+ /** `@font-face` rules for a style's local font files ('' when it has none). */
183
+ export function fontesLocaisCss(style: Pick<PrintStyle, 'fontesLocais'>): string {
184
+ return (style.fontesLocais ?? [])
185
+ .map(
186
+ (f) =>
187
+ `@font-face { font-family: "${f.familia.replace(/"/g, '')}"; src: url("${f.src.replace(/"/g, '%22')}") format("woff2"); font-weight: ${f.peso ?? 400}; font-style: ${f.estilo ?? 'normal'}; font-display: block; }`,
188
+ )
189
+ .join('\n')
190
+ }
191
+
192
+ /** Trim and bleed of a style (defaults 170 × 240 mm, 3 mm bleed). */
193
+ export function paginaDoEstilo(style: Pick<PrintStyle, 'pagina'>): Required<PrintPagina> {
194
+ const p = style.pagina ?? {}
195
+ return { largura: p.largura ?? 170, altura: p.altura ?? 240, sangria: p.sangria ?? 3, marcasCorte: p.marcasCorte ?? false }
196
+ }
197
+
198
+ /** A neutral example theme (registered nowhere by default): a small press over `semanario`, in two inks. */
199
+ export const TEMA_EXEMPLO_EDITORA: PrintBookTheme = {
200
+ id: 'editora-exemplo',
201
+ base: 'semanario',
202
+ label: 'Editora Exemplo · coleção ensaios',
203
+ fontes: { titulo: '"Source Serif 4", Georgia, serif' },
204
+ cor: { destaque: '#1f4e79', destaque2: '#b5562b', marcaTexto: '#e8eef5' },
205
+ pb: { destaque: '#333333', destaque2: '#8a8a8a' },
206
+ texto: { nomesProprios: 'preservar' },
207
+ folio: { folio: 'centro', cabeco: 'externo' },
208
+ pagina: { largura: 160, altura: 230, sangria: 3 },
209
+ }
210
+
211
+
212
+ /** Families named in a style's font stacks (quotes removed), in role order, without generic and system faces. */
213
+ export function familiasDoEstilo(style: Pick<PrintStyle, 'fontes'>): string[] {
214
+ const out: string[] = []
215
+ for (const pilha of Object.values(style.fontes) as string[])
216
+ for (const f of pilha.split(',').map((x) => x.trim().replace(/^["']|["']$/g, '')))
217
+ if (f && !SYSTEM_FONT_FAMILIES.includes(f) && !out.includes(f)) out.push(f)
218
+ return out
219
+ }
220
+
221
+ /** Families the style names (first of each stack) that have neither a bundled file nor a theme `fontesLocais`. */
222
+ export function familiasSemArquivo(style: Pick<PrintStyle, 'fontes' | 'fontesLocais'>, manifest: FontManifest): string[] {
223
+ const proprias = new Set((style.fontesLocais ?? []).map((f) => f.familia))
224
+ const primeiras = (Object.values(style.fontes) as string[]).map((p) => p.split(',')[0]!.trim().replace(/^["']|["']$/g, ''))
225
+ return [...new Set(primeiras)].filter((f) => !SYSTEM_FONT_FAMILIES.includes(f) && !proprias.has(f) && !manifest.families[f])
226
+ }
227
+
228
+ /**
229
+ * @font-face rules for a style from the fonts bundled in this package (`fonts/manifest.json`): every face of
230
+ * every family its font stacks name (so a mono or label family outside `googleFonts` is not forgotten), with
231
+ * URLs `<base><file>`, then the theme's own `fontesLocais`, which win for their family.
232
+ */
233
+ export function fontesEmpacotadasCss(style: Pick<PrintStyle, 'fontes' | 'googleFonts' | 'fontesLocais'>, manifest: FontManifest, base: string): string {
234
+ const proprias = new Set((style.fontesLocais ?? []).map((f) => f.familia))
235
+ const familias = new Set(familiasDoEstilo(style).filter((f) => manifest.families[f] && !proprias.has(f)))
236
+ // Every bundled spec of those families (faces are de-duplicated by fontFaceCss).
237
+ const specs = Object.keys(manifest.specs).filter((s) => familias.has(s.split(':')[0]!))
238
+ return [specs.length ? fontFaceCss(manifest, specs, base) : '', fontesLocaisCss(style)].filter(Boolean).join('\n')
239
+ }