@nowline/layout 0.8.5 → 0.9.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 (339) hide show
  1. package/dist/axis-columns.d.ts +70 -0
  2. package/dist/axis-columns.d.ts.map +1 -0
  3. package/dist/axis-columns.js +104 -0
  4. package/dist/axis-columns.js.map +1 -0
  5. package/dist/band-scale.d.ts.map +1 -1
  6. package/dist/calendar-resolver.d.ts +27 -0
  7. package/dist/calendar-resolver.d.ts.map +1 -0
  8. package/dist/calendar-resolver.js +132 -0
  9. package/dist/calendar-resolver.js.map +1 -0
  10. package/dist/calendar.d.ts +0 -19
  11. package/dist/calendar.d.ts.map +1 -1
  12. package/dist/calendar.js +4 -44
  13. package/dist/calendar.js.map +1 -1
  14. package/dist/canvas-width.d.ts +23 -0
  15. package/dist/canvas-width.d.ts.map +1 -0
  16. package/dist/canvas-width.js +26 -0
  17. package/dist/canvas-width.js.map +1 -0
  18. package/dist/capacity.d.ts +28 -0
  19. package/dist/capacity.d.ts.map +1 -1
  20. package/dist/capacity.js +64 -29
  21. package/dist/capacity.js.map +1 -1
  22. package/dist/chart-translation.d.ts +22 -0
  23. package/dist/chart-translation.d.ts.map +1 -0
  24. package/dist/chart-translation.js +83 -0
  25. package/dist/chart-translation.js.map +1 -0
  26. package/dist/container-header-geometry.d.ts +64 -0
  27. package/dist/container-header-geometry.d.ts.map +1 -0
  28. package/dist/container-header-geometry.js +89 -0
  29. package/dist/container-header-geometry.js.map +1 -0
  30. package/dist/dsl-utils.d.ts +3 -4
  31. package/dist/dsl-utils.d.ts.map +1 -1
  32. package/dist/dsl-utils.js +8 -25
  33. package/dist/dsl-utils.js.map +1 -1
  34. package/dist/edge-routing.d.ts +69 -78
  35. package/dist/edge-routing.d.ts.map +1 -1
  36. package/dist/edge-routing.js +271 -401
  37. package/dist/edge-routing.js.map +1 -1
  38. package/dist/frame-tab-geometry.d.ts +24 -14
  39. package/dist/frame-tab-geometry.d.ts.map +1 -1
  40. package/dist/frame-tab-geometry.js +69 -23
  41. package/dist/frame-tab-geometry.js.map +1 -1
  42. package/dist/header-card-geometry.d.ts +12 -0
  43. package/dist/header-card-geometry.d.ts.map +1 -1
  44. package/dist/header-card-geometry.js +14 -2
  45. package/dist/header-card-geometry.js.map +1 -1
  46. package/dist/header-logo.d.ts +28 -0
  47. package/dist/header-logo.d.ts.map +1 -0
  48. package/dist/header-logo.js +51 -0
  49. package/dist/header-logo.js.map +1 -0
  50. package/dist/i18n.d.ts +11 -0
  51. package/dist/i18n.d.ts.map +1 -1
  52. package/dist/i18n.js +11 -1
  53. package/dist/i18n.js.map +1 -1
  54. package/dist/index.d.ts +20 -8
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +17 -7
  57. package/dist/index.js.map +1 -1
  58. package/dist/inline-date-pin-geometry.d.ts +19 -11
  59. package/dist/inline-date-pin-geometry.d.ts.map +1 -1
  60. package/dist/inline-date-pin-geometry.js +45 -46
  61. package/dist/inline-date-pin-geometry.js.map +1 -1
  62. package/dist/item-bar-geometry.d.ts +192 -4
  63. package/dist/item-bar-geometry.d.ts.map +1 -1
  64. package/dist/item-bar-geometry.js +258 -5
  65. package/dist/item-bar-geometry.js.map +1 -1
  66. package/dist/item-port-geometry.d.ts +13 -0
  67. package/dist/item-port-geometry.d.ts.map +1 -0
  68. package/dist/item-port-geometry.js +63 -0
  69. package/dist/item-port-geometry.js.map +1 -0
  70. package/dist/layout-context.d.ts +115 -51
  71. package/dist/layout-context.d.ts.map +1 -1
  72. package/dist/layout-context.js +96 -5
  73. package/dist/layout-context.js.map +1 -1
  74. package/dist/layout-insights.d.ts.map +1 -1
  75. package/dist/layout-insights.js +73 -1
  76. package/dist/layout-insights.js.map +1 -1
  77. package/dist/layout.d.ts +35 -0
  78. package/dist/layout.d.ts.map +1 -1
  79. package/dist/layout.js +18 -1315
  80. package/dist/layout.js.map +1 -1
  81. package/dist/nodes/anchor-node.d.ts.map +1 -1
  82. package/dist/nodes/anchor-node.js +11 -4
  83. package/dist/nodes/anchor-node.js.map +1 -1
  84. package/dist/nodes/content-extent.d.ts +24 -0
  85. package/dist/nodes/content-extent.d.ts.map +1 -0
  86. package/dist/nodes/content-extent.js +100 -0
  87. package/dist/nodes/content-extent.js.map +1 -0
  88. package/dist/nodes/dependency-edges.d.ts +6 -0
  89. package/dist/nodes/dependency-edges.d.ts.map +1 -0
  90. package/dist/nodes/dependency-edges.js +148 -0
  91. package/dist/nodes/dependency-edges.js.map +1 -0
  92. package/dist/nodes/footnote-node.d.ts +24 -3
  93. package/dist/nodes/footnote-node.d.ts.map +1 -1
  94. package/dist/nodes/footnote-node.js +129 -25
  95. package/dist/nodes/footnote-node.js.map +1 -1
  96. package/dist/nodes/group-node.d.ts +10 -21
  97. package/dist/nodes/group-node.d.ts.map +1 -1
  98. package/dist/nodes/group-node.js +79 -154
  99. package/dist/nodes/group-node.js.map +1 -1
  100. package/dist/nodes/header-node.d.ts +56 -0
  101. package/dist/nodes/header-node.d.ts.map +1 -0
  102. package/dist/nodes/header-node.js +163 -0
  103. package/dist/nodes/header-node.js.map +1 -0
  104. package/dist/nodes/include-node.d.ts +22 -12
  105. package/dist/nodes/include-node.d.ts.map +1 -1
  106. package/dist/nodes/include-node.js +95 -62
  107. package/dist/nodes/include-node.js.map +1 -1
  108. package/dist/nodes/item-measure.d.ts +135 -0
  109. package/dist/nodes/item-measure.d.ts.map +1 -0
  110. package/dist/nodes/item-measure.js +514 -0
  111. package/dist/nodes/item-measure.js.map +1 -0
  112. package/dist/nodes/item-node.d.ts +162 -9
  113. package/dist/nodes/item-node.d.ts.map +1 -1
  114. package/dist/nodes/item-node.js +256 -69
  115. package/dist/nodes/item-node.js.map +1 -1
  116. package/dist/nodes/marker-band.d.ts +118 -0
  117. package/dist/nodes/marker-band.d.ts.map +1 -0
  118. package/dist/nodes/marker-band.js +303 -0
  119. package/dist/nodes/marker-band.js.map +1 -0
  120. package/dist/nodes/marker-geometry.js +1 -1
  121. package/dist/nodes/marker-geometry.js.map +1 -1
  122. package/dist/nodes/milestone-node.d.ts +112 -15
  123. package/dist/nodes/milestone-node.d.ts.map +1 -1
  124. package/dist/nodes/milestone-node.js +246 -95
  125. package/dist/nodes/milestone-node.js.map +1 -1
  126. package/dist/nodes/nowline-node.d.ts +8 -0
  127. package/dist/nodes/nowline-node.d.ts.map +1 -0
  128. package/dist/nodes/nowline-node.js +74 -0
  129. package/dist/nodes/nowline-node.js.map +1 -0
  130. package/dist/nodes/parallel-node.d.ts +7 -12
  131. package/dist/nodes/parallel-node.d.ts.map +1 -1
  132. package/dist/nodes/parallel-node.js +66 -32
  133. package/dist/nodes/parallel-node.js.map +1 -1
  134. package/dist/nodes/region-markers.d.ts +41 -0
  135. package/dist/nodes/region-markers.d.ts.map +1 -0
  136. package/dist/nodes/region-markers.js +130 -0
  137. package/dist/nodes/region-markers.js.map +1 -0
  138. package/dist/nodes/roadmap-node.d.ts +9 -72
  139. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  140. package/dist/nodes/roadmap-node.js +38 -788
  141. package/dist/nodes/roadmap-node.js.map +1 -1
  142. package/dist/nodes/row-track.d.ts +33 -0
  143. package/dist/nodes/row-track.d.ts.map +1 -0
  144. package/dist/nodes/row-track.js +170 -0
  145. package/dist/nodes/row-track.js.map +1 -0
  146. package/dist/nodes/sequence-item.d.ts +25 -0
  147. package/dist/nodes/sequence-item.d.ts.map +1 -0
  148. package/dist/nodes/sequence-item.js +396 -0
  149. package/dist/nodes/sequence-item.js.map +1 -0
  150. package/dist/nodes/sequence-one.d.ts +5 -0
  151. package/dist/nodes/sequence-one.d.ts.map +1 -0
  152. package/dist/nodes/sequence-one.js +26 -0
  153. package/dist/nodes/sequence-one.js.map +1 -0
  154. package/dist/nodes/swimlane-node.d.ts +5 -17
  155. package/dist/nodes/swimlane-node.d.ts.map +1 -1
  156. package/dist/nodes/swimlane-node.js +62 -147
  157. package/dist/nodes/swimlane-node.js.map +1 -1
  158. package/dist/nodes/wave-node.d.ts +52 -0
  159. package/dist/nodes/wave-node.d.ts.map +1 -0
  160. package/dist/nodes/wave-node.js +483 -0
  161. package/dist/nodes/wave-node.js.map +1 -0
  162. package/dist/non-working-display.d.ts +14 -0
  163. package/dist/non-working-display.d.ts.map +1 -0
  164. package/dist/non-working-display.js +28 -0
  165. package/dist/non-working-display.js.map +1 -0
  166. package/dist/phases/date-window.d.ts +48 -0
  167. package/dist/phases/date-window.d.ts.map +1 -0
  168. package/dist/phases/date-window.js +130 -0
  169. package/dist/phases/date-window.js.map +1 -0
  170. package/dist/phases/finalize.d.ts +198 -0
  171. package/dist/phases/finalize.d.ts.map +1 -0
  172. package/dist/phases/finalize.js +1058 -0
  173. package/dist/phases/finalize.js.map +1 -0
  174. package/dist/phases/measure.d.ts +77 -0
  175. package/dist/phases/measure.d.ts.map +1 -0
  176. package/dist/phases/measure.js +222 -0
  177. package/dist/phases/measure.js.map +1 -0
  178. package/dist/phases/place.d.ts +28 -0
  179. package/dist/phases/place.d.ts.map +1 -0
  180. package/dist/phases/place.js +225 -0
  181. package/dist/phases/place.js.map +1 -0
  182. package/dist/renderable.d.ts.map +1 -1
  183. package/dist/renderable.js +23 -3
  184. package/dist/renderable.js.map +1 -1
  185. package/dist/resolve-today.d.ts +11 -1
  186. package/dist/resolve-today.d.ts.map +1 -1
  187. package/dist/resolve-today.js +26 -12
  188. package/dist/resolve-today.js.map +1 -1
  189. package/dist/row-packer.d.ts +42 -16
  190. package/dist/row-packer.d.ts.map +1 -1
  191. package/dist/row-packer.js +51 -78
  192. package/dist/row-packer.js.map +1 -1
  193. package/dist/schedule.d.ts +58 -1
  194. package/dist/schedule.d.ts.map +1 -1
  195. package/dist/schedule.js +280 -106
  196. package/dist/schedule.js.map +1 -1
  197. package/dist/start-rules.d.ts +27 -0
  198. package/dist/start-rules.d.ts.map +1 -0
  199. package/dist/start-rules.js +91 -0
  200. package/dist/start-rules.js.map +1 -0
  201. package/dist/start-terms.d.ts +23 -0
  202. package/dist/start-terms.d.ts.map +1 -0
  203. package/dist/start-terms.js +27 -0
  204. package/dist/start-terms.js.map +1 -0
  205. package/dist/status-dot.d.ts +46 -0
  206. package/dist/status-dot.d.ts.map +1 -0
  207. package/dist/status-dot.js +78 -0
  208. package/dist/status-dot.js.map +1 -0
  209. package/dist/style-resolution.d.ts.map +1 -1
  210. package/dist/style-resolution.js +23 -43
  211. package/dist/style-resolution.js.map +1 -1
  212. package/dist/text-measure.d.ts +28 -0
  213. package/dist/text-measure.d.ts.map +1 -0
  214. package/dist/text-measure.js +70 -0
  215. package/dist/text-measure.js.map +1 -0
  216. package/dist/themes/contrast.d.ts +4 -0
  217. package/dist/themes/contrast.d.ts.map +1 -0
  218. package/dist/themes/contrast.js +32 -0
  219. package/dist/themes/contrast.js.map +1 -0
  220. package/dist/themes/dark.d.ts.map +1 -1
  221. package/dist/themes/dark.js +28 -1
  222. package/dist/themes/dark.js.map +1 -1
  223. package/dist/themes/grayscale.d.ts.map +1 -1
  224. package/dist/themes/grayscale.js +28 -1
  225. package/dist/themes/grayscale.js.map +1 -1
  226. package/dist/themes/index.d.ts +4 -11
  227. package/dist/themes/index.d.ts.map +1 -1
  228. package/dist/themes/index.js +6 -4
  229. package/dist/themes/index.js.map +1 -1
  230. package/dist/themes/light.d.ts.map +1 -1
  231. package/dist/themes/light.js +28 -1
  232. package/dist/themes/light.js.map +1 -1
  233. package/dist/themes/shape.d.ts +31 -0
  234. package/dist/themes/shape.d.ts.map +1 -1
  235. package/dist/themes/shared.d.ts +44 -50
  236. package/dist/themes/shared.d.ts.map +1 -1
  237. package/dist/themes/shared.js +79 -83
  238. package/dist/themes/shared.js.map +1 -1
  239. package/dist/time-scale.d.ts +42 -6
  240. package/dist/time-scale.d.ts.map +1 -1
  241. package/dist/time-scale.js +109 -17
  242. package/dist/time-scale.js.map +1 -1
  243. package/dist/types.d.ts +528 -11
  244. package/dist/types.d.ts.map +1 -1
  245. package/dist/view-preset.d.ts +61 -6
  246. package/dist/view-preset.d.ts.map +1 -1
  247. package/dist/view-preset.js +439 -24
  248. package/dist/view-preset.js.map +1 -1
  249. package/dist/wave-barrier.d.ts +107 -0
  250. package/dist/wave-barrier.d.ts.map +1 -0
  251. package/dist/wave-barrier.js +174 -0
  252. package/dist/wave-barrier.js.map +1 -0
  253. package/dist/wave-hatch.d.ts +4 -0
  254. package/dist/wave-hatch.d.ts.map +1 -0
  255. package/dist/wave-hatch.js +21 -0
  256. package/dist/wave-hatch.js.map +1 -0
  257. package/dist/wave-layout.d.ts +99 -0
  258. package/dist/wave-layout.d.ts.map +1 -0
  259. package/dist/wave-layout.js +130 -0
  260. package/dist/wave-layout.js.map +1 -0
  261. package/dist/working-calendar.d.ts +128 -4
  262. package/dist/working-calendar.d.ts.map +1 -1
  263. package/dist/working-calendar.js +725 -51
  264. package/dist/working-calendar.js.map +1 -1
  265. package/dist/working-span.d.ts +11 -0
  266. package/dist/working-span.d.ts.map +1 -0
  267. package/dist/working-span.js +18 -0
  268. package/dist/working-span.js.map +1 -0
  269. package/package.json +7 -7
  270. package/src/axis-columns.ts +140 -0
  271. package/src/calendar-resolver.ts +161 -0
  272. package/src/calendar.ts +4 -45
  273. package/src/canvas-width.ts +36 -0
  274. package/src/capacity.ts +71 -30
  275. package/src/chart-translation.ts +88 -0
  276. package/src/container-header-geometry.ts +116 -0
  277. package/src/dsl-utils.ts +8 -26
  278. package/src/edge-routing.ts +347 -491
  279. package/src/frame-tab-geometry.ts +78 -25
  280. package/src/header-card-geometry.ts +17 -2
  281. package/src/header-logo.ts +67 -0
  282. package/src/i18n.ts +22 -1
  283. package/src/index.ts +97 -12
  284. package/src/inline-date-pin-geometry.ts +55 -58
  285. package/src/item-bar-geometry.ts +341 -5
  286. package/src/item-port-geometry.ts +69 -0
  287. package/src/layout-context.ts +210 -72
  288. package/src/layout-insights.ts +109 -2
  289. package/src/layout.ts +57 -1530
  290. package/src/nodes/anchor-node.ts +11 -4
  291. package/src/nodes/content-extent.ts +107 -0
  292. package/src/nodes/dependency-edges.ts +171 -0
  293. package/src/nodes/footnote-node.ts +171 -31
  294. package/src/nodes/group-node.ts +92 -215
  295. package/src/nodes/header-node.ts +255 -0
  296. package/src/nodes/include-node.ts +124 -110
  297. package/src/nodes/item-measure.ts +723 -0
  298. package/src/nodes/item-node.ts +358 -80
  299. package/src/nodes/marker-band.ts +406 -0
  300. package/src/nodes/marker-geometry.ts +1 -1
  301. package/src/nodes/milestone-node.ts +321 -136
  302. package/src/nodes/nowline-node.ts +87 -0
  303. package/src/nodes/parallel-node.ts +69 -39
  304. package/src/nodes/region-markers.ts +194 -0
  305. package/src/nodes/roadmap-node.ts +46 -950
  306. package/src/nodes/row-track.ts +217 -0
  307. package/src/nodes/sequence-item.ts +433 -0
  308. package/src/nodes/sequence-one.ts +40 -0
  309. package/src/nodes/swimlane-node.ts +66 -206
  310. package/src/nodes/wave-node.ts +609 -0
  311. package/src/non-working-display.ts +32 -0
  312. package/src/phases/date-window.ts +191 -0
  313. package/src/phases/finalize.ts +1303 -0
  314. package/src/phases/measure.ts +329 -0
  315. package/src/phases/place.ts +318 -0
  316. package/src/renderable.ts +23 -3
  317. package/src/resolve-today.ts +28 -25
  318. package/src/row-packer.ts +85 -84
  319. package/src/schedule.ts +362 -114
  320. package/src/start-rules.ts +113 -0
  321. package/src/start-terms.ts +38 -0
  322. package/src/status-dot.ts +81 -0
  323. package/src/style-resolution.ts +27 -57
  324. package/src/text-measure.ts +71 -0
  325. package/src/themes/contrast.ts +34 -0
  326. package/src/themes/dark.ts +28 -1
  327. package/src/themes/grayscale.ts +28 -1
  328. package/src/themes/index.ts +10 -7
  329. package/src/themes/light.ts +28 -1
  330. package/src/themes/shape.ts +38 -0
  331. package/src/themes/shared.ts +85 -93
  332. package/src/time-scale.ts +117 -19
  333. package/src/types.ts +529 -20
  334. package/src/view-preset.ts +520 -27
  335. package/src/wave-barrier.ts +242 -0
  336. package/src/wave-hatch.ts +25 -0
  337. package/src/wave-layout.ts +203 -0
  338. package/src/working-calendar.ts +875 -57
  339. package/src/working-span.ts +19 -0
@@ -0,0 +1,113 @@
1
+ // Start rules — engine A's (the pixel layout's) start rule for a lane or
2
+ // group child, built on the parsed terms of `start-terms.ts`: the latest of
3
+ // the sequential default and the `after:` terms (refs and inline dates),
4
+ // then the wave floor (specs/waves.md §8.4). Also the inline
5
+ // `after:` date that reports NL.I1008 when it set an item's start on a
6
+ // non-working day (specs/working-calendar.md §6).
7
+
8
+ import type { EntityProperty, GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
9
+ import { formatIsoDate } from './dsl-utils.js';
10
+ import type { LayoutContext } from './layout-context.js';
11
+ import { startTermsOf } from './start-terms.js';
12
+ import type { PositionedItem } from './types.js';
13
+ import { dateAtX, WAVE_EDGE_TOLERANCE_PX, waveFloorX } from './wave-layout.js';
14
+
15
+ // Resolve the desired startX for a swimlane child: the latest of the
16
+ // sequential default (`seqDefault`, the lane's rightmost time cursor across
17
+ // all rows) and every `after:` term. Work has no fixed start pin, so
18
+ // `after:` only ever delays (specs/dsl.md § Item Properties).
19
+ //
20
+ // `after:` accepts both entity ids (looked up in `ctx.entityRightEdges`) and
21
+ // inline ISO date literals (looked up via `ctx.scale.forwardWithinDomain`).
22
+ // The validator already enforces "at most one inline date per direction" so
23
+ // at most one element in the list will hit the date path.
24
+ //
25
+ // In a roadmap with waves the result is floored by the child's wave and,
26
+ // for a container, its lead wave (specs/waves.md §8.4), so the row
27
+ // packer's predicted start matches the start `sequenceItem` / the
28
+ // container node places.
29
+ export function resolveChildStart(
30
+ child: ItemDeclaration | GroupBlock | ParallelBlock,
31
+ seqDefault: number,
32
+ ctx: LayoutContext,
33
+ ): number {
34
+ return waveFloorX(
35
+ child,
36
+ resolvePinnedOrSequentialStart(child.properties, seqDefault, ctx),
37
+ ctx,
38
+ );
39
+ }
40
+
41
+ let scheduleWarmStart = true;
42
+
43
+ /**
44
+ * Turn the warm start from the day schedule on or off: the barrier seed of
45
+ * the place phase. Tests turn it off to compare against a cold start (same
46
+ * output, more barrier passes); layout never does.
47
+ */
48
+ export function setScheduleWarmStart(enabled: boolean): void {
49
+ scheduleWarmStart = enabled;
50
+ }
51
+
52
+ /** True unless a test turned the warm start off (`setScheduleWarmStart`). */
53
+ export function isScheduleWarmStart(): boolean {
54
+ return scheduleWarmStart;
55
+ }
56
+
57
+ /**
58
+ * The start before the wave floor: the latest `after:` end (ids and inline
59
+ * dates inside the window) no earlier than `seqDefault`, else `seqDefault`.
60
+ */
61
+ export function resolvePinnedOrSequentialStart(
62
+ props: EntityProperty[],
63
+ seqDefault: number,
64
+ ctx: LayoutContext,
65
+ ): number {
66
+ const { after } = startTermsOf(props);
67
+ if (after.length > 0) {
68
+ let maxEnd = seqDefault;
69
+ for (const term of after) {
70
+ if ('date' in term) {
71
+ const xd = ctx.scale.forwardWithinDomain(term.date);
72
+ if (xd !== null) maxEnd = Math.max(maxEnd, xd);
73
+ continue;
74
+ }
75
+ const endX = ctx.entityRightEdges.get(term.ref);
76
+ if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
77
+ }
78
+ return maxEnd;
79
+ }
80
+ return seqDefault;
81
+ }
82
+
83
+ /**
84
+ * The inline `after:` date on a non-working day that set an item's start
85
+ * (NL.I1008, specs/working-calendar.md §6), for an item placed at `startX`.
86
+ * It reports only when it set the start, that is when the item starts at
87
+ * the date's x (within the edge tolerance): a later `after:` ref, the lane
88
+ * cursor or a wave floor that pushed the item past it never reports. A date
89
+ * on a working day or outside the date window reports nothing.
90
+ */
91
+ export function nonWorkingPinOf(
92
+ props: EntityProperty[],
93
+ startX: number,
94
+ ctx: LayoutContext,
95
+ ): PositionedItem['nonWorkingPin'] {
96
+ if (!ctx.calendar.hasNonWorkingDays) return undefined;
97
+ const pin = startPinOf(props);
98
+ if (!pin || ctx.calendar.isWorkingDay(pin.date)) return undefined;
99
+ const pinX = ctx.scale.forwardWithinDomain(pin.date);
100
+ if (pinX === null || Math.abs(startX - pinX) >= WAVE_EDGE_TOLERANCE_PX) return undefined;
101
+ return { key: pin.key, pin: pin.raw, start: formatIsoDate(dateAtX(startX, ctx)) };
102
+ }
103
+
104
+ /** The inline `after:` date that can set an item's start. */
105
+ function startPinOf(
106
+ props: EntityProperty[],
107
+ ): { key: 'after'; raw: string; date: Date } | undefined {
108
+ const { after } = startTermsOf(props);
109
+ for (const term of after) {
110
+ if ('date' in term) return { key: 'after', raw: term.raw, date: term.date };
111
+ }
112
+ return undefined;
113
+ }
@@ -0,0 +1,38 @@
1
+ // Start terms and wave floors, parsed once for every engine that places an
2
+ // item (specs/scheduler-unification.md §3.4): engine C's day walker, engine
3
+ // A's pixel walker and its non-working-pin helper, and the wave barrier's
4
+ // floor projections. Each caller keeps its own `max` rule and maps the
5
+ // parsed dates to its own unit (working-day index or x); only the parsing
6
+ // is shared. Pure: no calendar, no scale. Work has no fixed start pin:
7
+ // `date:` and `start:` on an item are unknown keys (specs/dsl.md § Item
8
+ // Properties, "Pinning is not supported"), so nothing here reads them.
9
+
10
+ import type { EntityProperty, WavePlan } from '@nowline/core';
11
+ import { parseIsoDate, propValues } from './dsl-utils.js';
12
+
13
+ /** One element of `after:`: an ISO date, or the id of an entity. */
14
+ export type AfterTerm = { raw: string; date: Date } | { raw: string; ref: string };
15
+
16
+ export interface StartTerms {
17
+ /** `after:` elements in source order: an ISO date or an id. */
18
+ after: ReadonlyArray<AfterTerm>;
19
+ }
20
+
21
+ /** The start terms of an item, group or parallel from its properties. */
22
+ export function startTermsOf(props: EntityProperty[]): StartTerms {
23
+ const after = propValues(props, 'after').map((raw): AfterTerm => {
24
+ const date = parseIsoDate(raw);
25
+ return date ? { raw, date } : { raw, ref: raw };
26
+ });
27
+ return { after };
28
+ }
29
+
30
+ /**
31
+ * The declaration date of each wave's floor (`null` for a wave without a
32
+ * floor or whose date does not parse), at index `k - 1` for wave `k`. The
33
+ * dates are the plan's unclamped declarations: the day engine maps them with
34
+ * `workingIndexOf`, the pixel engine with `scale.forward`.
35
+ */
36
+ export function waveFloorDates(plan: WavePlan): Array<Date | null> {
37
+ return plan.floors.map((f) => (f ? parseIsoDate(f.date) : null));
38
+ }
@@ -0,0 +1,81 @@
1
+ // Status-dot colour: the bar fill's luminance picks one of the theme's two
2
+ // status-dot palettes, and the item's status picks the colour in it. Moved
3
+ // from the SVG renderer (R5) so each positioned item carries its resolved
4
+ // dot colour and the renderer only draws the dot.
5
+
6
+ import type { Theme } from './themes/shape.js';
7
+ import type { StatusKind } from './types.js';
8
+
9
+ /**
10
+ * sRGB → relative luminance per WCAG 2.x. Input may be `#rrggbb`,
11
+ * `#rgb`, or a non-hex token like `none` / `transparent`. Non-hex
12
+ * inputs return 1 (treated as light) so a transparent bar reuses
13
+ * the chart's light bg. Mostly used to choose between two
14
+ * status-dot palettes (`onLight` vs `onDark`) so the dot reads on
15
+ * any bar fill — see `pickStatusDotPalette` and
16
+ * `specs/rendering.md`'s status-dot section.
17
+ *
18
+ * This is the renderer's copy, kept with its semantics: unlike
19
+ * `relativeLuminance` in `themes/contrast.ts`, which throws on anything
20
+ * but `#rgb` / `#rrggbb`, it never throws, and a 6-character string that
21
+ * is not hex gives `NaN` (so it compares false against any threshold).
22
+ */
23
+ export function fillLuminance(hex: string): number {
24
+ if (!hex || hex === 'none' || hex === 'transparent') return 1;
25
+ let h = hex.startsWith('#') ? hex.slice(1) : hex;
26
+ if (h.length === 3) {
27
+ h = h
28
+ .split('')
29
+ .map((c) => c + c)
30
+ .join('');
31
+ }
32
+ if (h.length !== 6) return 1;
33
+ const r = parseInt(h.slice(0, 2), 16) / 255;
34
+ const g = parseInt(h.slice(2, 4), 16) / 255;
35
+ const b = parseInt(h.slice(4, 6), 16) / 255;
36
+ const lin = (c: number): number => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
37
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
38
+ }
39
+
40
+ /**
41
+ * Pick the status-dot palette whose tone contrasts best with the
42
+ * given bar bg.
43
+ *
44
+ * The crossover threshold is the bar luminance at which a deep dot
45
+ * (avg luminance ≈ 0.045 across `onLight` palette entries) and a
46
+ * pale dot (avg ≈ 0.85 across `onDark` entries) give equal WCAG
47
+ * contrast. Solving `(L_bar + 0.05)² ≈ 0.86 × 0.095` gives
48
+ * `L_bar ≈ 0.24`, so:
49
+ * - bars with luminance ≥ 0.24 (most label-driven mid-tones AND
50
+ * all pale status-tint bars) → `onLight` deep dot
51
+ * - bars with luminance < 0.24 (dark status-tint bars in dark
52
+ * theme, e.g. `#172554`) → `onDark` pale dot
53
+ */
54
+ export function pickStatusDotPalette(bg: string, palette: Theme): Theme['statusDot']['onLight'] {
55
+ return fillLuminance(bg) >= 0.24 ? palette.statusDot.onLight : palette.statusDot.onDark;
56
+ }
57
+
58
+ /**
59
+ * The status dot's colour for a bar filled `bg` with status `status`.
60
+ *
61
+ * Status-dot color — the dot communicates status via hue, but
62
+ * the bar bg can range from pale status tints (`#eff6ff`) to
63
+ * saturated mid-tones (`#1e88e5` from `bg:blue` labels) to
64
+ * dark navies (`#172554` in dark theme), so a single palette
65
+ * can't keep contrast across all bars. Two palettes — `onLight`
66
+ * (deep tints, for pale bars) and `onDark` (pale tints, for
67
+ * saturated/dark bars) — are picked from based on the bar
68
+ * bg's relative luminance.
69
+ */
70
+ export function statusDotColor(status: StatusKind, bg: string, palette: Theme): string {
71
+ const dotPalette = pickStatusDotPalette(bg, palette);
72
+ const statusColors: Record<string, string> = {
73
+ done: dotPalette.done,
74
+ 'in-progress': dotPalette.inProgress,
75
+ 'at-risk': dotPalette.atRisk,
76
+ blocked: dotPalette.blocked,
77
+ planned: dotPalette.planned,
78
+ neutral: dotPalette.neutral,
79
+ };
80
+ return statusColors[status] ?? statusColors.neutral;
81
+ }
@@ -5,6 +5,8 @@ import type {
5
5
  StyleDeclaration,
6
6
  StyleProperty,
7
7
  } from '@nowline/core';
8
+ import { propKey, styleLayers } from '@nowline/core/internal';
9
+ import { isNonWorkingDisplay } from './non-working-display.js';
8
10
  import { resolveColor } from './themes/index.js';
9
11
  import type { EntityStyle, Theme } from './themes/shape.js';
10
12
  import type { ResolvedStyle, SizeBucket } from './types.js';
@@ -31,10 +33,6 @@ export interface StyleContext {
31
33
  labels: Map<string, LabelDeclaration>;
32
34
  }
33
35
 
34
- function propKey(prop: { key: string }): string {
35
- return prop.key.endsWith(':') ? prop.key.slice(0, -1) : prop.key;
36
- }
37
-
38
36
  function entityStyleToResolved(e: EntityStyle, theme: Theme): ResolvedStyle {
39
37
  return {
40
38
  bg: resolveColor(e.bg, theme),
@@ -61,6 +59,7 @@ function entityStyleToResolved(e: EntityStyle, theme: Theme): ResolvedStyle {
61
59
  // single-top-strip layout and keep the major-ticks-only grid.
62
60
  timelinePosition: 'top',
63
61
  minorGrid: false,
62
+ nonWorking: 'hide',
64
63
  };
65
64
  }
66
65
 
@@ -136,6 +135,9 @@ function applyProp(target: ResolvedStyle, key: string, value: string, theme: The
136
135
  case 'minor-grid':
137
136
  target.minorGrid = value === 'true';
138
137
  break;
138
+ case 'non-working':
139
+ if (isNonWorkingDisplay(value)) target.nonWorking = value;
140
+ break;
139
141
  default:
140
142
  break;
141
143
  }
@@ -161,7 +163,11 @@ function applyStyleDecl(
161
163
  }
162
164
  }
163
165
 
164
- function applyProperties(target: ResolvedStyle, props: EntityProperty[], theme: Theme): void {
166
+ function applyProperties(
167
+ target: ResolvedStyle,
168
+ props: readonly EntityProperty[],
169
+ theme: Theme,
170
+ ): void {
165
171
  for (const p of props) {
166
172
  const key = propKey(p);
167
173
  if (p.value !== undefined) {
@@ -170,42 +176,14 @@ function applyProperties(target: ResolvedStyle, props: EntityProperty[], theme:
170
176
  }
171
177
  }
172
178
 
173
- function applyLabelStyleRefs(
174
- target: ResolvedStyle,
175
- props: EntityProperty[],
176
- ctx: StyleContext,
177
- ): void {
178
- const labelsProp = props.find((p) => propKey(p) === 'labels');
179
- if (!labelsProp) return;
180
- const names: string[] = labelsProp.value ? [labelsProp.value] : labelsProp.values;
181
- for (const name of names) {
182
- const label = ctx.labels.get(name);
183
- if (!label) continue;
184
- // Label's `style:` ref gets applied.
185
- const styleRef = label.properties.find((p) => propKey(p) === 'style');
186
- if (styleRef?.value) {
187
- applyStyleDecl(target, ctx.styles.get(styleRef.value), ctx.theme);
188
- }
189
- }
190
- }
191
-
192
- function applyEntityStyleRef(
193
- target: ResolvedStyle,
194
- props: EntityProperty[],
195
- ctx: StyleContext,
196
- ): void {
197
- const styleProp = props.find((p) => propKey(p) === 'style');
198
- if (!styleProp?.value) return;
199
- applyStyleDecl(target, ctx.styles.get(styleProp.value), ctx.theme);
200
- }
201
-
202
179
  // Five-level precedence chain from specs/rendering.md § Style Precedence:
203
180
  // 1. system default (theme's EntityStyle for this type)
204
- // 2. config `default <entity>` properties
205
- // 3. label `style:` refs (per applied label)
206
- // 4. entity's own `style:` ref
207
- // 5. inline style properties on the entity (banned by validator for roadmap
208
- // entities; still supported for declared styles / defaults)
181
+ // 2-5. core's `styleLayers`: the config `default <entity>` line (its `style:`
182
+ // ref, then its raw properties), label `style:` refs (the first label wins
183
+ // a shared key), the entity's own `style:` ref, then its inline properties
184
+ // (banned by the validator for roadmap entities; still supported for
185
+ // declared styles / defaults). A style or label this file does not declare
186
+ // changes nothing.
209
187
  export function resolveStyle(
210
188
  entityType: EntityTypeKey,
211
189
  props: EntityProperty[],
@@ -213,26 +191,18 @@ export function resolveStyle(
213
191
  ): ResolvedStyle {
214
192
  const baseEntity = ctx.theme.entities[entityType];
215
193
  const out = entityStyleToResolved(baseEntity, ctx.theme);
216
-
217
- // Level 2: config defaults
218
- const defaultDecl = ctx.defaults.get(entityType);
219
- if (defaultDecl) {
220
- for (const p of defaultDecl.properties) {
221
- if (p.value !== undefined) {
222
- applyProp(out, propKey(p), p.value, ctx.theme);
223
- }
194
+ for (const layer of styleLayers(entityType, props, ctx)) {
195
+ switch (layer.level) {
196
+ case 'default':
197
+ case 'inline':
198
+ applyProperties(out, layer.props, ctx.theme);
199
+ break;
200
+ case 'label':
201
+ break;
202
+ default:
203
+ applyStyleDecl(out, layer.decl, ctx.theme);
224
204
  }
225
205
  }
226
-
227
- // Level 3: label styles (labels on this entity, each label's `style:` ref)
228
- applyLabelStyleRefs(out, props, ctx);
229
-
230
- // Level 4: entity's own `style:` ref
231
- applyEntityStyleRef(out, props, ctx);
232
-
233
- // Level 5: inline props on the entity
234
- applyProperties(out, props, ctx.theme);
235
-
236
206
  return out;
237
207
  }
238
208
 
@@ -0,0 +1,71 @@
1
+ // Shared text-measurement heuristics for layout.
2
+ //
3
+ // Layout has no font metrics, so every caption / header / marker fit
4
+ // decision runs on one pessimistic per-character estimate. Keeping the
5
+ // estimate and the whitespace word-wrap built on it in a single module
6
+ // means the item-title wrap (`measureItem`) and the header-card wrap in
7
+ // `layout.ts` can never disagree about how wide a string is.
8
+
9
+ /**
10
+ * Rough px-width estimate for sans-serif text. Intentionally pessimistic
11
+ * (~0.58 em per char) so we err toward "doesn't fit" and trigger a
12
+ * spill or row bump rather than draw a title that clips.
13
+ */
14
+ export function estimateTextWidth(text: string, fontSize: number): number {
15
+ return text.length * fontSize * 0.58;
16
+ }
17
+
18
+ /**
19
+ * `text` when it fits `maxWidth` (in px), else its longest prefix that fits
20
+ * with a trailing `…`, trailing whitespace dropped before the ellipsis. It
21
+ * always keeps the first character, so the result can overrun `maxWidth`
22
+ * when even one character and the ellipsis do not fit. Counts code points,
23
+ * so a surrogate pair is never split.
24
+ */
25
+ export function ellipsizeToWidth(text: string, maxWidth: number, fontSize: number): string {
26
+ if (estimateTextWidth(text, fontSize) <= maxWidth) return text;
27
+ const chars = Array.from(text.trimStart());
28
+ for (let n = chars.length - 1; n >= 1; n--) {
29
+ const prefix = chars.slice(0, n).join('').trimEnd();
30
+ const candidate = `${prefix}…`;
31
+ if (estimateTextWidth(candidate, fontSize) <= maxWidth) return candidate;
32
+ }
33
+ return `${chars[0] ?? ''}…`;
34
+ }
35
+
36
+ /**
37
+ * Word-wrap `text` so that no line is wider than `maxWidth` (in px).
38
+ * Wrap points are whitespace only: a long single word is kept on its
39
+ * own line even if it overflows `maxWidth` — we never split a word in
40
+ * the middle. The wrap is greedy (fill each line before starting the
41
+ * next), which yields the minimum possible line count for a given
42
+ * width.
43
+ *
44
+ * `firstLineMaxWidth` (default `maxWidth`) narrows only the FIRST line,
45
+ * for callers whose first line has to stay clear of something the later
46
+ * lines are below (the item bar's top-right decorations).
47
+ */
48
+ export function wrapText(
49
+ text: string,
50
+ maxWidth: number,
51
+ fontSize: number,
52
+ firstLineMaxWidth: number = maxWidth,
53
+ ): string[] {
54
+ if (!text) return [];
55
+ const words = text.split(/\s+/).filter((w) => w.length > 0);
56
+ if (words.length === 0) return [];
57
+ const lines: string[] = [];
58
+ let cur = '';
59
+ for (const word of words) {
60
+ const trial = cur ? `${cur} ${word}` : word;
61
+ const limit = lines.length === 0 ? firstLineMaxWidth : maxWidth;
62
+ if (cur && estimateTextWidth(trial, fontSize) > limit) {
63
+ lines.push(cur);
64
+ cur = word;
65
+ } else {
66
+ cur = trial;
67
+ }
68
+ }
69
+ if (cur) lines.push(cur);
70
+ return lines;
71
+ }
@@ -0,0 +1,34 @@
1
+ // WCAG 2.x contrast helpers over `#rgb` / `#rrggbb` hex colours. Used by the
2
+ // theme contrast-floor tests and by wave strip label colour selection.
3
+
4
+ export function hexToRgb(hex: string): [number, number, number] {
5
+ let h = hex.trim().replace(/^#/, '');
6
+ if (h.length === 3) {
7
+ h = h
8
+ .split('')
9
+ .map((c) => c + c)
10
+ .join('');
11
+ }
12
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) {
13
+ throw new Error(`Unsupported hex colour: ${hex}`);
14
+ }
15
+ return [
16
+ Number.parseInt(h.slice(0, 2), 16),
17
+ Number.parseInt(h.slice(2, 4), 16),
18
+ Number.parseInt(h.slice(4, 6), 16),
19
+ ];
20
+ }
21
+
22
+ export function relativeLuminance(hex: string): number {
23
+ const [r, g, b] = hexToRgb(hex).map((v) => {
24
+ const c = v / 255;
25
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
26
+ }) as [number, number, number];
27
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
28
+ }
29
+
30
+ export function contrastRatio(a: string, b: string): number {
31
+ const la = relativeLuminance(a);
32
+ const lb = relativeLuminance(b);
33
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
34
+ }
@@ -62,13 +62,19 @@ export const darkTheme: Theme = {
62
62
  shadow: 'subtle',
63
63
  cornerRadius: 'sm',
64
64
  },
65
+ // Containers shade their body with `bg` as a light tint
66
+ // (CONTAINER_BODY_FILL_OPACITY); groups and parallels get distinct
67
+ // ones, and a parallel's header bracket is dashed, a group's solid
68
+ // (specs/true-width-layout.md §3.3).
65
69
  parallel: {
66
70
  ...baseEntity,
67
- bracket: 'none',
71
+ bg: '#8c84ff',
72
+ bracket: 'dashed',
68
73
  padding: 'xs',
69
74
  },
70
75
  group: {
71
76
  ...baseEntity,
77
+ bg: '#f0a832',
72
78
  bracket: 'solid',
73
79
  padding: 'xs',
74
80
  fg: '#94a3b8',
@@ -125,6 +131,8 @@ export const darkTheme: Theme = {
125
131
  timeline: {
126
132
  gridLine: '#475569',
127
133
  minorGridLine: '#334155',
134
+ nonWorkingSeam: '#6b7a90',
135
+ nonWorkingFill: '#94a3b8',
128
136
  tickMark: '#334155',
129
137
  labelText: '#cbd5e1',
130
138
  panelFill: '#0f172a',
@@ -197,6 +205,15 @@ export const darkTheme: Theme = {
197
205
  badgeStroke: '#475569',
198
206
  badgeText: '#94a3b8',
199
207
  },
208
+ wave: {
209
+ stripFill: '#042f2e',
210
+ stripFillAlt: '#134e4a',
211
+ labelText: '#99f6e4',
212
+ labelMuted: '#5eead4',
213
+ boundary: '#2dd4bf',
214
+ hatch: '#0f172a',
215
+ hatchOnDark: '#ffffff',
216
+ },
200
217
  arrowhead: {
201
218
  neutral: '#94a3b8',
202
219
  light: '#64748b',
@@ -210,6 +227,16 @@ export const darkTheme: Theme = {
210
227
  planned: '#94a3b8',
211
228
  neutral: '#94a3b8',
212
229
  },
230
+ // Item bar fill per status, applied by layout when the item's resolved
231
+ // `bg` is still `entities.item.bg` (m2d Resolution 3).
232
+ statusTint: {
233
+ done: '#052e16',
234
+ inProgress: '#172554',
235
+ atRisk: '#422006',
236
+ blocked: '#7f1d1d',
237
+ planned: '#1e293b',
238
+ neutral: '#1e293b',
239
+ },
213
240
  // Status-dot palettes — see `light.ts` for the contract. Same
214
241
  // two palettes both themes; dark-theme bars come in BOTH dark
215
242
  // (status-tint) and bright (label-driven) flavors, so a single
@@ -63,13 +63,19 @@ export const grayscaleTheme: Theme = {
63
63
  shadow: 'subtle',
64
64
  cornerRadius: 'sm',
65
65
  },
66
+ // Containers shade their body with `bg` as a light tint
67
+ // (CONTAINER_BODY_FILL_OPACITY); groups and parallels get distinct
68
+ // ones, and a parallel's header bracket is dashed, a group's solid
69
+ // (specs/true-width-layout.md §3.3).
66
70
  parallel: {
67
71
  ...baseEntity,
68
- bracket: 'none',
72
+ bg: '#9e9e9e',
73
+ bracket: 'dashed',
69
74
  padding: 'xs',
70
75
  },
71
76
  group: {
72
77
  ...baseEntity,
78
+ bg: '#595959',
73
79
  bracket: 'solid',
74
80
  padding: 'xs',
75
81
  fg: '#595959',
@@ -126,6 +132,8 @@ export const grayscaleTheme: Theme = {
126
132
  timeline: {
127
133
  gridLine: '#bdbdbd',
128
134
  minorGridLine: '#d4d4d4',
135
+ nonWorkingSeam: '#9a9a9a',
136
+ nonWorkingFill: '#737373',
129
137
  tickMark: '#bdbdbd',
130
138
  labelText: '#737373',
131
139
  panelFill: '#ffffff',
@@ -198,6 +206,15 @@ export const grayscaleTheme: Theme = {
198
206
  badgeStroke: '#bdbdbd',
199
207
  badgeText: '#595959',
200
208
  },
209
+ wave: {
210
+ stripFill: '#fafafa',
211
+ stripFillAlt: '#e0e0e0',
212
+ labelText: '#212121',
213
+ labelMuted: '#616161',
214
+ boundary: '#616161',
215
+ hatch: '#000000',
216
+ hatchOnDark: '#ffffff',
217
+ },
201
218
  arrowhead: {
202
219
  neutral: '#595959',
203
220
  light: '#9e9e9e',
@@ -211,6 +228,16 @@ export const grayscaleTheme: Theme = {
211
228
  planned: '#bdbdbd',
212
229
  neutral: '#bdbdbd',
213
230
  },
231
+ // Item bar fill per status, applied by layout when the item's resolved
232
+ // `bg` is still `entities.item.bg` (m2d Resolution 3).
233
+ statusTint: {
234
+ done: '#ebebeb',
235
+ inProgress: '#e4e4e4',
236
+ atRisk: '#eeeeee',
237
+ blocked: '#dcdcdc',
238
+ planned: '#f5f5f5',
239
+ neutral: '#f5f5f5',
240
+ },
214
241
  statusDot: {
215
242
  onLight: {
216
243
  done: '#3a3a3a',
@@ -8,15 +8,21 @@ import { grayscaleNamed, grayscaleTheme } from './grayscale.js';
8
8
  import { lightNamed, lightTheme } from './light.js';
9
9
  import type { NamedColors, Theme } from './shape.js';
10
10
 
11
- export type ThemeName = 'light' | 'dark' | 'grayscale';
11
+ // The one list of theme names. `ThemeName` derives from it and `themes` /
12
+ // `namedColors` are keyed by it, so a new theme is added here and tsc then
13
+ // demands its palette. Every surface that validates a theme (CLI `render` /
14
+ // `serve`, the GitHub Action) goes through `normalizeThemeName` below.
15
+ export const THEME_NAMES = ['light', 'dark', 'grayscale'] as const;
12
16
 
13
- export const themes: { light: Theme; dark: Theme; grayscale: Theme } = {
17
+ export type ThemeName = (typeof THEME_NAMES)[number];
18
+
19
+ export const themes: Record<ThemeName, Theme> = {
14
20
  light: lightTheme,
15
21
  dark: darkTheme,
16
22
  grayscale: grayscaleTheme,
17
23
  };
18
24
 
19
- export const namedColors: { light: NamedColors; dark: NamedColors; grayscale: NamedColors } = {
25
+ export const namedColors: Record<ThemeName, NamedColors> = {
20
26
  light: lightNamed,
21
27
  dark: darkNamed,
22
28
  grayscale: grayscaleNamed,
@@ -37,10 +43,7 @@ const THEME_ALIASES: Record<string, ThemeName> = {
37
43
  export function normalizeThemeName(raw: string): ThemeName | undefined {
38
44
  const lower = raw.toLowerCase();
39
45
  const canonical = THEME_ALIASES[lower] ?? lower;
40
- if (canonical === 'light' || canonical === 'dark' || canonical === 'grayscale') {
41
- return canonical;
42
- }
43
- return undefined;
46
+ return THEME_NAMES.find((name) => name === canonical);
44
47
  }
45
48
 
46
49
  // Aliases collapse internationally-friendlier spellings onto the canonical