@nowline/layout 0.8.6 → 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 (338) 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/calendar-resolver.d.ts +27 -0
  6. package/dist/calendar-resolver.d.ts.map +1 -0
  7. package/dist/calendar-resolver.js +132 -0
  8. package/dist/calendar-resolver.js.map +1 -0
  9. package/dist/calendar.d.ts +0 -19
  10. package/dist/calendar.d.ts.map +1 -1
  11. package/dist/calendar.js +4 -44
  12. package/dist/calendar.js.map +1 -1
  13. package/dist/canvas-width.d.ts +23 -0
  14. package/dist/canvas-width.d.ts.map +1 -0
  15. package/dist/canvas-width.js +26 -0
  16. package/dist/canvas-width.js.map +1 -0
  17. package/dist/capacity.d.ts +28 -0
  18. package/dist/capacity.d.ts.map +1 -1
  19. package/dist/capacity.js +64 -29
  20. package/dist/capacity.js.map +1 -1
  21. package/dist/chart-translation.d.ts +22 -0
  22. package/dist/chart-translation.d.ts.map +1 -0
  23. package/dist/chart-translation.js +83 -0
  24. package/dist/chart-translation.js.map +1 -0
  25. package/dist/container-header-geometry.d.ts +64 -0
  26. package/dist/container-header-geometry.d.ts.map +1 -0
  27. package/dist/container-header-geometry.js +89 -0
  28. package/dist/container-header-geometry.js.map +1 -0
  29. package/dist/dsl-utils.d.ts +3 -4
  30. package/dist/dsl-utils.d.ts.map +1 -1
  31. package/dist/dsl-utils.js +8 -25
  32. package/dist/dsl-utils.js.map +1 -1
  33. package/dist/edge-routing.d.ts +69 -78
  34. package/dist/edge-routing.d.ts.map +1 -1
  35. package/dist/edge-routing.js +271 -401
  36. package/dist/edge-routing.js.map +1 -1
  37. package/dist/frame-tab-geometry.d.ts +24 -14
  38. package/dist/frame-tab-geometry.d.ts.map +1 -1
  39. package/dist/frame-tab-geometry.js +69 -23
  40. package/dist/frame-tab-geometry.js.map +1 -1
  41. package/dist/header-card-geometry.d.ts +12 -0
  42. package/dist/header-card-geometry.d.ts.map +1 -1
  43. package/dist/header-card-geometry.js +14 -2
  44. package/dist/header-card-geometry.js.map +1 -1
  45. package/dist/header-logo.d.ts +28 -0
  46. package/dist/header-logo.d.ts.map +1 -0
  47. package/dist/header-logo.js +51 -0
  48. package/dist/header-logo.js.map +1 -0
  49. package/dist/i18n.d.ts +11 -0
  50. package/dist/i18n.d.ts.map +1 -1
  51. package/dist/i18n.js +11 -1
  52. package/dist/i18n.js.map +1 -1
  53. package/dist/index.d.ts +20 -8
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -7
  56. package/dist/index.js.map +1 -1
  57. package/dist/inline-date-pin-geometry.d.ts +19 -11
  58. package/dist/inline-date-pin-geometry.d.ts.map +1 -1
  59. package/dist/inline-date-pin-geometry.js +45 -46
  60. package/dist/inline-date-pin-geometry.js.map +1 -1
  61. package/dist/item-bar-geometry.d.ts +192 -4
  62. package/dist/item-bar-geometry.d.ts.map +1 -1
  63. package/dist/item-bar-geometry.js +258 -5
  64. package/dist/item-bar-geometry.js.map +1 -1
  65. package/dist/item-port-geometry.d.ts +13 -0
  66. package/dist/item-port-geometry.d.ts.map +1 -0
  67. package/dist/item-port-geometry.js +63 -0
  68. package/dist/item-port-geometry.js.map +1 -0
  69. package/dist/layout-context.d.ts +115 -51
  70. package/dist/layout-context.d.ts.map +1 -1
  71. package/dist/layout-context.js +96 -5
  72. package/dist/layout-context.js.map +1 -1
  73. package/dist/layout-insights.d.ts.map +1 -1
  74. package/dist/layout-insights.js +73 -1
  75. package/dist/layout-insights.js.map +1 -1
  76. package/dist/layout.d.ts +35 -0
  77. package/dist/layout.d.ts.map +1 -1
  78. package/dist/layout.js +18 -1315
  79. package/dist/layout.js.map +1 -1
  80. package/dist/nodes/anchor-node.d.ts.map +1 -1
  81. package/dist/nodes/anchor-node.js +11 -4
  82. package/dist/nodes/anchor-node.js.map +1 -1
  83. package/dist/nodes/content-extent.d.ts +24 -0
  84. package/dist/nodes/content-extent.d.ts.map +1 -0
  85. package/dist/nodes/content-extent.js +100 -0
  86. package/dist/nodes/content-extent.js.map +1 -0
  87. package/dist/nodes/dependency-edges.d.ts +6 -0
  88. package/dist/nodes/dependency-edges.d.ts.map +1 -0
  89. package/dist/nodes/dependency-edges.js +148 -0
  90. package/dist/nodes/dependency-edges.js.map +1 -0
  91. package/dist/nodes/footnote-node.d.ts +24 -3
  92. package/dist/nodes/footnote-node.d.ts.map +1 -1
  93. package/dist/nodes/footnote-node.js +129 -25
  94. package/dist/nodes/footnote-node.js.map +1 -1
  95. package/dist/nodes/group-node.d.ts +10 -21
  96. package/dist/nodes/group-node.d.ts.map +1 -1
  97. package/dist/nodes/group-node.js +79 -152
  98. package/dist/nodes/group-node.js.map +1 -1
  99. package/dist/nodes/header-node.d.ts +56 -0
  100. package/dist/nodes/header-node.d.ts.map +1 -0
  101. package/dist/nodes/header-node.js +163 -0
  102. package/dist/nodes/header-node.js.map +1 -0
  103. package/dist/nodes/include-node.d.ts +22 -12
  104. package/dist/nodes/include-node.d.ts.map +1 -1
  105. package/dist/nodes/include-node.js +95 -62
  106. package/dist/nodes/include-node.js.map +1 -1
  107. package/dist/nodes/item-measure.d.ts +135 -0
  108. package/dist/nodes/item-measure.d.ts.map +1 -0
  109. package/dist/nodes/item-measure.js +514 -0
  110. package/dist/nodes/item-measure.js.map +1 -0
  111. package/dist/nodes/item-node.d.ts +162 -9
  112. package/dist/nodes/item-node.d.ts.map +1 -1
  113. package/dist/nodes/item-node.js +256 -69
  114. package/dist/nodes/item-node.js.map +1 -1
  115. package/dist/nodes/marker-band.d.ts +118 -0
  116. package/dist/nodes/marker-band.d.ts.map +1 -0
  117. package/dist/nodes/marker-band.js +303 -0
  118. package/dist/nodes/marker-band.js.map +1 -0
  119. package/dist/nodes/marker-geometry.js +1 -1
  120. package/dist/nodes/marker-geometry.js.map +1 -1
  121. package/dist/nodes/milestone-node.d.ts +112 -15
  122. package/dist/nodes/milestone-node.d.ts.map +1 -1
  123. package/dist/nodes/milestone-node.js +246 -95
  124. package/dist/nodes/milestone-node.js.map +1 -1
  125. package/dist/nodes/nowline-node.d.ts +8 -0
  126. package/dist/nodes/nowline-node.d.ts.map +1 -0
  127. package/dist/nodes/nowline-node.js +74 -0
  128. package/dist/nodes/nowline-node.js.map +1 -0
  129. package/dist/nodes/parallel-node.d.ts +7 -12
  130. package/dist/nodes/parallel-node.d.ts.map +1 -1
  131. package/dist/nodes/parallel-node.js +66 -30
  132. package/dist/nodes/parallel-node.js.map +1 -1
  133. package/dist/nodes/region-markers.d.ts +41 -0
  134. package/dist/nodes/region-markers.d.ts.map +1 -0
  135. package/dist/nodes/region-markers.js +130 -0
  136. package/dist/nodes/region-markers.js.map +1 -0
  137. package/dist/nodes/roadmap-node.d.ts +9 -72
  138. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  139. package/dist/nodes/roadmap-node.js +38 -788
  140. package/dist/nodes/roadmap-node.js.map +1 -1
  141. package/dist/nodes/row-track.d.ts +33 -0
  142. package/dist/nodes/row-track.d.ts.map +1 -0
  143. package/dist/nodes/row-track.js +170 -0
  144. package/dist/nodes/row-track.js.map +1 -0
  145. package/dist/nodes/sequence-item.d.ts +25 -0
  146. package/dist/nodes/sequence-item.d.ts.map +1 -0
  147. package/dist/nodes/sequence-item.js +396 -0
  148. package/dist/nodes/sequence-item.js.map +1 -0
  149. package/dist/nodes/sequence-one.d.ts +5 -0
  150. package/dist/nodes/sequence-one.d.ts.map +1 -0
  151. package/dist/nodes/sequence-one.js +26 -0
  152. package/dist/nodes/sequence-one.js.map +1 -0
  153. package/dist/nodes/swimlane-node.d.ts +5 -17
  154. package/dist/nodes/swimlane-node.d.ts.map +1 -1
  155. package/dist/nodes/swimlane-node.js +62 -145
  156. package/dist/nodes/swimlane-node.js.map +1 -1
  157. package/dist/nodes/wave-node.d.ts +52 -0
  158. package/dist/nodes/wave-node.d.ts.map +1 -0
  159. package/dist/nodes/wave-node.js +483 -0
  160. package/dist/nodes/wave-node.js.map +1 -0
  161. package/dist/non-working-display.d.ts +14 -0
  162. package/dist/non-working-display.d.ts.map +1 -0
  163. package/dist/non-working-display.js +28 -0
  164. package/dist/non-working-display.js.map +1 -0
  165. package/dist/phases/date-window.d.ts +48 -0
  166. package/dist/phases/date-window.d.ts.map +1 -0
  167. package/dist/phases/date-window.js +130 -0
  168. package/dist/phases/date-window.js.map +1 -0
  169. package/dist/phases/finalize.d.ts +198 -0
  170. package/dist/phases/finalize.d.ts.map +1 -0
  171. package/dist/phases/finalize.js +1058 -0
  172. package/dist/phases/finalize.js.map +1 -0
  173. package/dist/phases/measure.d.ts +77 -0
  174. package/dist/phases/measure.d.ts.map +1 -0
  175. package/dist/phases/measure.js +222 -0
  176. package/dist/phases/measure.js.map +1 -0
  177. package/dist/phases/place.d.ts +28 -0
  178. package/dist/phases/place.d.ts.map +1 -0
  179. package/dist/phases/place.js +225 -0
  180. package/dist/phases/place.js.map +1 -0
  181. package/dist/renderable.d.ts.map +1 -1
  182. package/dist/renderable.js +23 -3
  183. package/dist/renderable.js.map +1 -1
  184. package/dist/resolve-today.d.ts +11 -1
  185. package/dist/resolve-today.d.ts.map +1 -1
  186. package/dist/resolve-today.js +26 -12
  187. package/dist/resolve-today.js.map +1 -1
  188. package/dist/row-packer.d.ts +42 -16
  189. package/dist/row-packer.d.ts.map +1 -1
  190. package/dist/row-packer.js +51 -78
  191. package/dist/row-packer.js.map +1 -1
  192. package/dist/schedule.d.ts +58 -1
  193. package/dist/schedule.d.ts.map +1 -1
  194. package/dist/schedule.js +280 -106
  195. package/dist/schedule.js.map +1 -1
  196. package/dist/start-rules.d.ts +27 -0
  197. package/dist/start-rules.d.ts.map +1 -0
  198. package/dist/start-rules.js +91 -0
  199. package/dist/start-rules.js.map +1 -0
  200. package/dist/start-terms.d.ts +23 -0
  201. package/dist/start-terms.d.ts.map +1 -0
  202. package/dist/start-terms.js +27 -0
  203. package/dist/start-terms.js.map +1 -0
  204. package/dist/status-dot.d.ts +46 -0
  205. package/dist/status-dot.d.ts.map +1 -0
  206. package/dist/status-dot.js +78 -0
  207. package/dist/status-dot.js.map +1 -0
  208. package/dist/style-resolution.d.ts.map +1 -1
  209. package/dist/style-resolution.js +23 -43
  210. package/dist/style-resolution.js.map +1 -1
  211. package/dist/text-measure.d.ts +28 -0
  212. package/dist/text-measure.d.ts.map +1 -0
  213. package/dist/text-measure.js +70 -0
  214. package/dist/text-measure.js.map +1 -0
  215. package/dist/themes/contrast.d.ts +4 -0
  216. package/dist/themes/contrast.d.ts.map +1 -0
  217. package/dist/themes/contrast.js +32 -0
  218. package/dist/themes/contrast.js.map +1 -0
  219. package/dist/themes/dark.d.ts.map +1 -1
  220. package/dist/themes/dark.js +28 -1
  221. package/dist/themes/dark.js.map +1 -1
  222. package/dist/themes/grayscale.d.ts.map +1 -1
  223. package/dist/themes/grayscale.js +28 -1
  224. package/dist/themes/grayscale.js.map +1 -1
  225. package/dist/themes/index.d.ts +4 -11
  226. package/dist/themes/index.d.ts.map +1 -1
  227. package/dist/themes/index.js +6 -4
  228. package/dist/themes/index.js.map +1 -1
  229. package/dist/themes/light.d.ts.map +1 -1
  230. package/dist/themes/light.js +28 -1
  231. package/dist/themes/light.js.map +1 -1
  232. package/dist/themes/shape.d.ts +31 -0
  233. package/dist/themes/shape.d.ts.map +1 -1
  234. package/dist/themes/shared.d.ts +44 -50
  235. package/dist/themes/shared.d.ts.map +1 -1
  236. package/dist/themes/shared.js +79 -83
  237. package/dist/themes/shared.js.map +1 -1
  238. package/dist/time-scale.d.ts +42 -6
  239. package/dist/time-scale.d.ts.map +1 -1
  240. package/dist/time-scale.js +109 -17
  241. package/dist/time-scale.js.map +1 -1
  242. package/dist/types.d.ts +528 -11
  243. package/dist/types.d.ts.map +1 -1
  244. package/dist/view-preset.d.ts +61 -6
  245. package/dist/view-preset.d.ts.map +1 -1
  246. package/dist/view-preset.js +439 -24
  247. package/dist/view-preset.js.map +1 -1
  248. package/dist/wave-barrier.d.ts +107 -0
  249. package/dist/wave-barrier.d.ts.map +1 -0
  250. package/dist/wave-barrier.js +174 -0
  251. package/dist/wave-barrier.js.map +1 -0
  252. package/dist/wave-hatch.d.ts +4 -0
  253. package/dist/wave-hatch.d.ts.map +1 -0
  254. package/dist/wave-hatch.js +21 -0
  255. package/dist/wave-hatch.js.map +1 -0
  256. package/dist/wave-layout.d.ts +99 -0
  257. package/dist/wave-layout.d.ts.map +1 -0
  258. package/dist/wave-layout.js +130 -0
  259. package/dist/wave-layout.js.map +1 -0
  260. package/dist/working-calendar.d.ts +128 -4
  261. package/dist/working-calendar.d.ts.map +1 -1
  262. package/dist/working-calendar.js +725 -51
  263. package/dist/working-calendar.js.map +1 -1
  264. package/dist/working-span.d.ts +11 -0
  265. package/dist/working-span.d.ts.map +1 -0
  266. package/dist/working-span.js +18 -0
  267. package/dist/working-span.js.map +1 -0
  268. package/package.json +4 -4
  269. package/src/axis-columns.ts +140 -0
  270. package/src/calendar-resolver.ts +161 -0
  271. package/src/calendar.ts +4 -45
  272. package/src/canvas-width.ts +36 -0
  273. package/src/capacity.ts +71 -30
  274. package/src/chart-translation.ts +88 -0
  275. package/src/container-header-geometry.ts +116 -0
  276. package/src/dsl-utils.ts +8 -26
  277. package/src/edge-routing.ts +347 -491
  278. package/src/frame-tab-geometry.ts +78 -25
  279. package/src/header-card-geometry.ts +17 -2
  280. package/src/header-logo.ts +67 -0
  281. package/src/i18n.ts +22 -1
  282. package/src/index.ts +97 -12
  283. package/src/inline-date-pin-geometry.ts +55 -58
  284. package/src/item-bar-geometry.ts +341 -5
  285. package/src/item-port-geometry.ts +69 -0
  286. package/src/layout-context.ts +210 -72
  287. package/src/layout-insights.ts +109 -2
  288. package/src/layout.ts +57 -1530
  289. package/src/nodes/anchor-node.ts +11 -4
  290. package/src/nodes/content-extent.ts +107 -0
  291. package/src/nodes/dependency-edges.ts +171 -0
  292. package/src/nodes/footnote-node.ts +171 -31
  293. package/src/nodes/group-node.ts +92 -214
  294. package/src/nodes/header-node.ts +255 -0
  295. package/src/nodes/include-node.ts +124 -110
  296. package/src/nodes/item-measure.ts +723 -0
  297. package/src/nodes/item-node.ts +358 -80
  298. package/src/nodes/marker-band.ts +406 -0
  299. package/src/nodes/marker-geometry.ts +1 -1
  300. package/src/nodes/milestone-node.ts +321 -136
  301. package/src/nodes/nowline-node.ts +87 -0
  302. package/src/nodes/parallel-node.ts +69 -38
  303. package/src/nodes/region-markers.ts +194 -0
  304. package/src/nodes/roadmap-node.ts +46 -950
  305. package/src/nodes/row-track.ts +217 -0
  306. package/src/nodes/sequence-item.ts +433 -0
  307. package/src/nodes/sequence-one.ts +40 -0
  308. package/src/nodes/swimlane-node.ts +66 -205
  309. package/src/nodes/wave-node.ts +609 -0
  310. package/src/non-working-display.ts +32 -0
  311. package/src/phases/date-window.ts +191 -0
  312. package/src/phases/finalize.ts +1303 -0
  313. package/src/phases/measure.ts +329 -0
  314. package/src/phases/place.ts +318 -0
  315. package/src/renderable.ts +23 -3
  316. package/src/resolve-today.ts +28 -25
  317. package/src/row-packer.ts +85 -84
  318. package/src/schedule.ts +362 -114
  319. package/src/start-rules.ts +113 -0
  320. package/src/start-terms.ts +38 -0
  321. package/src/status-dot.ts +81 -0
  322. package/src/style-resolution.ts +27 -57
  323. package/src/text-measure.ts +71 -0
  324. package/src/themes/contrast.ts +34 -0
  325. package/src/themes/dark.ts +28 -1
  326. package/src/themes/grayscale.ts +28 -1
  327. package/src/themes/index.ts +10 -7
  328. package/src/themes/light.ts +28 -1
  329. package/src/themes/shape.ts +38 -0
  330. package/src/themes/shared.ts +85 -93
  331. package/src/time-scale.ts +117 -19
  332. package/src/types.ts +529 -20
  333. package/src/view-preset.ts +520 -27
  334. package/src/wave-barrier.ts +242 -0
  335. package/src/wave-hatch.ts +25 -0
  336. package/src/wave-layout.ts +203 -0
  337. package/src/working-calendar.ts +875 -57
  338. package/src/working-span.ts +19 -0
@@ -0,0 +1,609 @@
1
+ // Positioned waves (specs/waves.md §8.7, §9.1-§9.5): the solved spans engine
2
+ // A's barrier driver settled on, in declaration order, plus everything the
3
+ // renderer draws for them: strip cells (label fit chain, tooltip, empty-wave
4
+ // diamonds, gap labels, the all-empty placeholder), styled colours, boundary
5
+ // lines, crossings over background bars, and the legend.
6
+ //
7
+ // Built from the frozen `WaveLayoutState` after the post-layout extent growth
8
+ // and the marker-row `deltaY` shift, so the dates read the final scale and
9
+ // every y is final. Growth only extends the domain end (origin and
10
+ // pixels-per-day are unchanged), so the spans' x never move.
11
+
12
+ import type { StyleProperty } from '@nowline/core';
13
+ import { propKey } from '@nowline/core/internal';
14
+ import { propValue } from '../dsl-utils.js';
15
+ import type { LocaleStrings } from '../i18n.js';
16
+ import { pickInlineDate } from '../inline-date-pin-geometry.js';
17
+ import {
18
+ INLINE_DATE_GLYPH_GAP_PX,
19
+ INLINE_DATE_GLYPH_INSET_LEFT_PX,
20
+ INLINE_DATE_GLYPH_TILE_SIZE_PX,
21
+ ITEM_FOOTNOTE_INDICATOR_STEP_PX,
22
+ } from '../item-bar-geometry.js';
23
+ import type { LayoutContext } from '../layout-context.js';
24
+ import { estimateTextWidth } from '../text-measure.js';
25
+ import { contrastRatio, hexToRgb } from '../themes/contrast.js';
26
+ import { resolveColor } from '../themes/index.js';
27
+ import type { Theme } from '../themes/shape.js';
28
+ import {
29
+ WAVE_CELL_INSET_PX,
30
+ WAVE_LABEL_HALO_INSET_Y_PX,
31
+ WAVE_LABEL_HALO_PAD_PX,
32
+ WAVE_LEGEND_BASELINE_OFFSET_PX,
33
+ WAVE_LEGEND_LINE_PX,
34
+ WAVE_LEGEND_SWATCH_GAP_PX,
35
+ WAVE_LEGEND_SWATCH_HEIGHT_PX,
36
+ WAVE_LEGEND_SWATCH_WIDTH_PX,
37
+ WAVE_STRIP_LABEL_FONT_SIZE_PX,
38
+ WAVE_STRIP_LABEL_PAD_PX,
39
+ WAVE_STYLED_STRIP_MIX_OPACITY,
40
+ } from '../themes/shared.js';
41
+ import type {
42
+ BoundingBox,
43
+ InlineDatePin,
44
+ PositionedIncludeRegion,
45
+ PositionedItem,
46
+ PositionedSwimlane,
47
+ PositionedTrackChild,
48
+ PositionedWave,
49
+ PositionedWaveBoundary,
50
+ PositionedWaveCrossing,
51
+ PositionedWaveLegend,
52
+ PositionedWaveStyle,
53
+ WaveLegendEntry,
54
+ } from '../types.js';
55
+ import { summarizeWaves } from '../wave-barrier.js';
56
+ import { waveHatchKind } from '../wave-hatch.js';
57
+ import {
58
+ dateAtX,
59
+ spanEndDateAtX,
60
+ WAVE_EDGE_TOLERANCE_PX,
61
+ type WaveLayoutState,
62
+ } from '../wave-layout.js';
63
+ import { MARKER_BOLD_WIDTH_FACTOR } from './marker-geometry.js';
64
+
65
+ /** WCAG AA floor for the strip label against its composited cell fill. */
66
+ const WAVE_LABEL_MIN_CONTRAST = 4.5;
67
+
68
+ /** Everything `buildWaves` produces; the caller attaches it to the model. */
69
+ export interface BuiltWaves {
70
+ waves: PositionedWave[];
71
+ /** Boundary lines, left to right (empty when every wave is empty). */
72
+ boundaries: PositionedWaveBoundary[];
73
+ /** Crossings over the main lanes' background bars. */
74
+ crossings: PositionedWaveCrossing[];
75
+ /** Crossings over each isolated region's background bars, parallel to `includes`. */
76
+ regionCrossings: PositionedWaveCrossing[][];
77
+ /** True when any placed item (main lanes or regions) is background work. */
78
+ hasBackground: boolean;
79
+ }
80
+
81
+ /**
82
+ * Build the positioned waves, boundaries and crossings. `state` must be
83
+ * frozen: its `pass` is the pass that ran with the solved `S`/`E`, so its
84
+ * member counts cover the main lanes and the first-level regions exactly
85
+ * once (later placements never accumulate). `ctx.timeline.waveStrip` is
86
+ * set whenever some wave has items; when none does, nothing is drawn and the
87
+ * waves carry their spans only (specs/waves.md §9.1).
88
+ */
89
+ export function buildWaves(
90
+ state: WaveLayoutState,
91
+ ctx: LayoutContext,
92
+ strings: LocaleStrings,
93
+ swimlanes: PositionedSwimlane[],
94
+ includes: PositionedIncludeRegion[],
95
+ ): BuiltWaves {
96
+ if (!state.frozen || !state.solve) {
97
+ throw new Error('buildWaves: the wave barrier has not settled');
98
+ }
99
+ const spans = summarizeWaves(
100
+ state.plan,
101
+ state.solve,
102
+ state.pass,
103
+ state.origin,
104
+ state.floors,
105
+ WAVE_EDGE_TOLERANCE_PX,
106
+ );
107
+ const allEmpty = spans.every((s) => s.memberCount === 0);
108
+ // With every wave empty the roadmap draws no strip: the waves keep a
109
+ // zero-height one under the tick panel, for their boxes only.
110
+ const strip = ctx.timeline.waveStrip ?? {
111
+ y: ctx.timeline.tickPanelY + ctx.timeline.tickPanelHeight,
112
+ height: 0,
113
+ };
114
+ if (!ctx.timeline.waveStrip && !allEmpty) {
115
+ throw new Error('buildWaves: the timeline has no wave strip');
116
+ }
117
+ // The visible part of a span is its overlap with the timeline.
118
+ const visLeft = ctx.timeline.box.x;
119
+ const visRight = visLeft + ctx.timeline.box.width;
120
+ // Every strip halo spans the strip less a vertical inset.
121
+ const haloY = strip.y + WAVE_LABEL_HALO_INSET_Y_PX;
122
+ const haloHeight = strip.height - 2 * WAVE_LABEL_HALO_INSET_Y_PX;
123
+ let ordinal = 0;
124
+
125
+ const waves = spans.map((span, i): PositionedWave => {
126
+ const decl = state.plan.waves[i];
127
+ const title = decl.title ?? span.id;
128
+ const empty = span.memberCount === 0;
129
+ const startDate = dateAtX(span.start, ctx);
130
+ const endDate = spanEndDateAtX(span.start, span.end, ctx);
131
+ const visibleOrdinal = empty ? undefined : ordinal++;
132
+ const footnoteIndicators = waveFootnoteIndicators(span.id, ctx);
133
+ const style = waveStyle(decl.properties, visibleOrdinal, ctx);
134
+
135
+ const cellLeft = Math.max(span.start, visLeft);
136
+ const cellRight = Math.min(span.end, visRight);
137
+ const visibleWidth = Math.max(0, cellRight - cellLeft);
138
+
139
+ // A declared date floor (`after:2026-11-02`) puts a calendar glyph at
140
+ // the cell's left, whether or not the floor opened a gap (§9.1); the
141
+ // label clears it like it clears the superscripts.
142
+ const floorDate = pickInlineDate(
143
+ state.plan.floors[i]?.ref !== undefined ? [state.plan.floors[i].ref as string] : [],
144
+ );
145
+ const glyphFits =
146
+ floorDate !== undefined &&
147
+ visibleWidth >=
148
+ INLINE_DATE_GLYPH_INSET_LEFT_PX +
149
+ INLINE_DATE_GLYPH_TILE_SIZE_PX +
150
+ WAVE_STRIP_LABEL_PAD_PX;
151
+ const glyphReserve = glyphFits
152
+ ? INLINE_DATE_GLYPH_TILE_SIZE_PX + INLINE_DATE_GLYPH_GAP_PX
153
+ : 0;
154
+
155
+ // Fit chain (§9.1): try every candidate beside the footnote
156
+ // superscripts; when none fits with them, the superscripts move
157
+ // to the legend and the chain runs again on the label alone.
158
+ let fit: LabelFit | undefined;
159
+ let footnotesShown = false;
160
+ if (!empty) {
161
+ if (footnoteIndicators.length > 0) {
162
+ const superscriptWidth =
163
+ footnoteIndicators.length * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
164
+ fit = fitStripLabel(
165
+ title,
166
+ span.id,
167
+ i + 1,
168
+ visibleWidth,
169
+ superscriptWidth + glyphReserve,
170
+ );
171
+ footnotesShown = fit !== undefined;
172
+ }
173
+ fit ??= fitStripLabel(title, span.id, i + 1, visibleWidth, glyphReserve);
174
+ }
175
+
176
+ // An empty wave is not drawn (§9.1); the NL.I1006 insight names it.
177
+ const labelX = empty ? span.start : (cellLeft + cellRight) / 2;
178
+
179
+ const range = empty ? isoDay(startDate) : `${isoDay(startDate)} – ${isoDay(endDate)}`;
180
+ const tooltipParts = [title, range];
181
+ if (empty) tooltipParts.push(strings.waveNoItems);
182
+ else if (span.heldBy !== undefined) {
183
+ tooltipParts.push(`${strings.waveHeldBy} ${span.heldBy}`);
184
+ }
185
+
186
+ // A gap (S_k > E_{k-1}) shows the floor reference that opened it.
187
+ let gapLabel: PositionedWave['strip']['gapLabel'];
188
+ const prior = spans[i - 1];
189
+ if (
190
+ !allEmpty &&
191
+ prior !== undefined &&
192
+ span.floorRef !== undefined &&
193
+ span.start - prior.end > WAVE_EDGE_TOLERANCE_PX
194
+ ) {
195
+ const gapLeft = Math.max(prior.end, visLeft);
196
+ const gapRight = Math.min(span.start, visRight);
197
+ const textWidth = estimateTextWidth(span.floorRef, WAVE_STRIP_LABEL_FONT_SIZE_PX);
198
+ if (textWidth + 2 * WAVE_STRIP_LABEL_PAD_PX <= gapRight - gapLeft) {
199
+ const x = (gapLeft + gapRight) / 2;
200
+ const haloWidth = textWidth + 2 * WAVE_LABEL_HALO_PAD_PX;
201
+ gapLabel = {
202
+ text: span.floorRef,
203
+ x,
204
+ halo: { x: x - haloWidth / 2, y: haloY, width: haloWidth, height: haloHeight },
205
+ };
206
+ }
207
+ }
208
+
209
+ // The painted cell, and in it the label and superscript halos
210
+ // (§9.1): the superscripts right-aligned, the label centred on the
211
+ // visible span but never under them.
212
+ const stripBox: BoundingBox = {
213
+ x: span.start,
214
+ y: strip.y,
215
+ width: span.end - span.start,
216
+ height: strip.height,
217
+ };
218
+ const cell = empty ? undefined : stripCellBox(stripBox, ctx.timeline.box);
219
+ let labelDraw: PositionedWave['strip']['labelDraw'];
220
+ let superscripts: PositionedWave['strip']['superscripts'];
221
+ let inlineDatePin: InlineDatePin | undefined;
222
+ if (cell) {
223
+ if (glyphFits && floorDate !== undefined) {
224
+ inlineDatePin = {
225
+ side: 'after',
226
+ isoDate: floorDate,
227
+ glyphTopLeft: {
228
+ x: cell.x + INLINE_DATE_GLYPH_INSET_LEFT_PX,
229
+ y: strip.y + (strip.height - INLINE_DATE_GLYPH_TILE_SIZE_PX) / 2,
230
+ },
231
+ glyphSize: INLINE_DATE_GLYPH_TILE_SIZE_PX,
232
+ spilled: false,
233
+ };
234
+ }
235
+ const paintedRight = cell.x + cell.width;
236
+ const superscriptWidth = footnotesShown
237
+ ? footnoteIndicators.length * ITEM_FOOTNOTE_INDICATOR_STEP_PX
238
+ : 0;
239
+ if (superscriptWidth) {
240
+ const right = paintedRight - WAVE_STRIP_LABEL_PAD_PX + WAVE_LABEL_HALO_PAD_PX;
241
+ const left =
242
+ paintedRight -
243
+ WAVE_STRIP_LABEL_PAD_PX -
244
+ superscriptWidth -
245
+ WAVE_LABEL_HALO_PAD_PX;
246
+ superscripts = {
247
+ rightX: paintedRight - WAVE_STRIP_LABEL_PAD_PX,
248
+ halo: { x: left, y: haloY, width: right - left, height: haloHeight },
249
+ };
250
+ }
251
+ if (fit) {
252
+ const textWidth = stripLabelWidth(fit.label);
253
+ const rightBound = superscriptWidth
254
+ ? Math.min(
255
+ labelX,
256
+ paintedRight - WAVE_STRIP_LABEL_PAD_PX - superscriptWidth - textWidth / 2,
257
+ )
258
+ : labelX;
259
+ const x = inlineDatePin
260
+ ? Math.max(
261
+ rightBound,
262
+ inlineDatePin.glyphTopLeft.x +
263
+ INLINE_DATE_GLYPH_TILE_SIZE_PX +
264
+ INLINE_DATE_GLYPH_GAP_PX +
265
+ textWidth / 2,
266
+ )
267
+ : rightBound;
268
+ const haloWidth = textWidth + 2 * WAVE_LABEL_HALO_PAD_PX;
269
+ labelDraw = {
270
+ x,
271
+ halo: { x: x - haloWidth / 2, y: haloY, width: haloWidth, height: haloHeight },
272
+ };
273
+ }
274
+ }
275
+
276
+ return {
277
+ id: span.id,
278
+ title,
279
+ index: i,
280
+ ...(visibleOrdinal !== undefined ? { visibleOrdinal } : {}),
281
+ startX: span.start,
282
+ endX: span.end,
283
+ startDate,
284
+ endDate,
285
+ memberCount: span.memberCount,
286
+ empty,
287
+ ...(span.heldBy !== undefined ? { heldBy: span.heldBy } : {}),
288
+ ...(span.floorRef !== undefined ? { floorRef: span.floorRef } : {}),
289
+ columnBox: {
290
+ x: span.start,
291
+ y: ctx.chartTopY,
292
+ width: span.end - span.start,
293
+ height: ctx.swimlaneBottomY - ctx.chartTopY,
294
+ },
295
+ strip: {
296
+ box: {
297
+ x: span.start,
298
+ y: strip.y,
299
+ width: span.end - span.start,
300
+ height: strip.height,
301
+ },
302
+ ...(fit ? { label: fit.label } : {}),
303
+ labelKind: fit?.kind ?? 'none',
304
+ labelX,
305
+ ...(cell ? { cell } : {}),
306
+ ...(labelDraw ? { labelDraw } : {}),
307
+ ...(inlineDatePin ? { inlineDatePin } : {}),
308
+ ...(superscripts ? { superscripts } : {}),
309
+ tooltip: tooltipParts.join(' · '),
310
+ footnotesShown,
311
+ ...(gapLabel ? { gapLabel } : {}),
312
+ },
313
+ style,
314
+ footnoteIndicators,
315
+ };
316
+ });
317
+
318
+ const boundaries = allEmpty ? [] : buildBoundaries(waves, state.origin, strip.y, ctx);
319
+ const mainBackground = backgroundItems(swimlanes);
320
+ // Background items already carry their tooltip: `sequenceItem` sets it
321
+ // where it places them.
322
+ const regionBackground = includes.map((inc) => backgroundItems(inc.nestedSwimlanes));
323
+ const built: BuiltWaves = {
324
+ waves,
325
+ boundaries,
326
+ crossings: crossingsOver(mainBackground, boundaries),
327
+ regionCrossings: regionBackground.map((items) => crossingsOver(items, boundaries)),
328
+ hasBackground: mainBackground.length + regionBackground.flat().length > 0,
329
+ };
330
+ return built;
331
+ }
332
+
333
+ /**
334
+ * A strip cell's painted box: the wave's strip box clipped to the timeline's
335
+ * backing panel, inset by half the panel's 1 px stroke so the cell never
336
+ * covers it. Undefined when the two do not overlap (an off-screen wave).
337
+ */
338
+ function stripCellBox(box: BoundingBox, timelineBox: BoundingBox): BoundingBox | undefined {
339
+ const inset = WAVE_CELL_INSET_PX;
340
+ const a: BoundingBox = {
341
+ x: box.x,
342
+ y: box.y + inset,
343
+ width: box.width,
344
+ height: box.height - 2 * inset,
345
+ };
346
+ const b: BoundingBox = {
347
+ x: timelineBox.x + inset,
348
+ y: box.y,
349
+ width: timelineBox.width - 2 * inset,
350
+ height: box.height,
351
+ };
352
+ const x1 = Math.max(a.x, b.x);
353
+ const y1 = Math.max(a.y, b.y);
354
+ const x2 = Math.min(a.x + a.width, b.x + b.width);
355
+ const y2 = Math.min(a.y + a.height, b.y + b.height);
356
+ if (x2 <= x1 || y2 <= y1) return undefined;
357
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
358
+ }
359
+
360
+ interface LabelFit {
361
+ kind: 'title' | 'id' | 'ellipsis' | 'ordinal';
362
+ label: string;
363
+ }
364
+
365
+ /** A strip label's estimated width: 10 px text, widened for its bold weight. */
366
+ function stripLabelWidth(text: string): number {
367
+ return estimateTextWidth(text, WAVE_STRIP_LABEL_FONT_SIZE_PX) * MARKER_BOLD_WIDTH_FACTOR;
368
+ }
369
+
370
+ /**
371
+ * The first strip label candidate that fits (§9.1): the title, the id, the
372
+ * title ellipsized to 3 or more characters plus "…", then the 1-based
373
+ * `#k`. The test is the bold 10 px width estimate plus `extraWidth` (the
374
+ * footnote superscripts) plus 6 px padding on each side. Undefined: no
375
+ * label.
376
+ */
377
+ export function fitStripLabel(
378
+ title: string,
379
+ id: string,
380
+ ordinal: number,
381
+ visibleWidth: number,
382
+ extraWidth: number,
383
+ ): LabelFit | undefined {
384
+ const fits = (text: string): boolean =>
385
+ stripLabelWidth(text) + extraWidth + 2 * WAVE_STRIP_LABEL_PAD_PX <= visibleWidth;
386
+ if (fits(title)) return { kind: 'title', label: title };
387
+ if (id !== title && fits(id)) return { kind: 'id', label: id };
388
+ for (let n = title.length - 1; n >= 3; n--) {
389
+ const prefix = title.slice(0, n).trimEnd();
390
+ if (prefix.length < 3) break;
391
+ const text = `${prefix}…`;
392
+ if (fits(text)) return { kind: 'ellipsis', label: text };
393
+ }
394
+ const hash = `#${ordinal}`;
395
+ if (fits(hash)) return { kind: 'ordinal', label: hash };
396
+ return undefined;
397
+ }
398
+
399
+ /** Footnotes whose `on:` names the wave, as 1-based numbers, ascending. */
400
+ function waveFootnoteIndicators(id: string, ctx: LayoutContext): number[] {
401
+ const out: number[] = [];
402
+ for (const [fid, hosts] of ctx.footnoteHosts.entries()) {
403
+ if (!hosts.includes(id)) continue;
404
+ const n = ctx.footnoteIndex.get(fid);
405
+ if (n !== undefined) out.push(n);
406
+ }
407
+ return out.sort((a, b) => a - b);
408
+ }
409
+
410
+ /**
411
+ * A wave's colours (§9.4). Only the named `style:` applies (there is no
412
+ * `default wave`), and of it only `bg`, `fg`, `text` and `border`. The
413
+ * style is read raw.
414
+ */
415
+ function waveStyle(
416
+ properties: Parameters<typeof propValue>[0],
417
+ visibleOrdinal: number | undefined,
418
+ ctx: LayoutContext,
419
+ ): PositionedWaveStyle {
420
+ const theme = ctx.styleCtx.theme;
421
+ const raw = new Map<string, string>();
422
+ const styleId = propValue(properties, 'style');
423
+ const decl = styleId ? ctx.styleCtx.styles.get(styleId) : undefined;
424
+ for (const p of (decl?.properties ?? []) as StyleProperty[]) {
425
+ raw.set(propKey(p), p.value);
426
+ }
427
+ const color = (key: string): string | undefined => {
428
+ const v = raw.get(key);
429
+ if (v === undefined) return undefined;
430
+ const c = resolveColor(v, theme);
431
+ return c === 'none' ? undefined : c;
432
+ };
433
+ const tint = color('bg');
434
+ const stripFill =
435
+ visibleOrdinal !== undefined && visibleOrdinal % 2 === 1
436
+ ? theme.wave.stripFillAlt
437
+ : theme.wave.stripFill;
438
+ // The cell the label sits on: the strip fill under the tint overlay.
439
+ const cellFill = tint
440
+ ? (mixHex(stripFill, tint, WAVE_STYLED_STRIP_MIX_OPACITY) ?? stripFill)
441
+ : stripFill;
442
+ const border = raw.get('border');
443
+ return {
444
+ ...(tint ? { tint } : {}),
445
+ stripFill,
446
+ text: color('text') ?? readableLabelText(theme.wave, cellFill),
447
+ boundary: color('fg') ?? theme.wave.boundary,
448
+ boundaryDash: border === 'dashed' ? '4 2' : border === 'dotted' ? '1 2' : null,
449
+ };
450
+ }
451
+
452
+ const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
453
+
454
+ /** `top` composited over `base` at `alpha`, as `#rrggbb`; undefined unless both are hex. */
455
+ function mixHex(base: string, top: string, alpha: number): string | undefined {
456
+ if (!HEX_RE.test(base) || !HEX_RE.test(top)) return undefined;
457
+ const b = hexToRgb(base);
458
+ const t = hexToRgb(top);
459
+ return `#${b
460
+ .map((v, i) =>
461
+ Math.round(v * (1 - alpha) + t[i] * alpha)
462
+ .toString(16)
463
+ .padStart(2, '0'),
464
+ )
465
+ .join('')}`;
466
+ }
467
+
468
+ /**
469
+ * `wave.labelText` when it reaches 4.5:1 against `fill` (or either colour is
470
+ * not hex), else whichever of the theme's dark (`hatch`) and light
471
+ * (`hatchOnDark`) strokes contrasts more.
472
+ */
473
+ export function readableLabelText(
474
+ wave: Pick<Theme['wave'], 'labelText' | 'hatch' | 'hatchOnDark'>,
475
+ fill: string,
476
+ ): string {
477
+ const preferred = wave.labelText;
478
+ if (!HEX_RE.test(preferred) || !HEX_RE.test(fill)) return preferred;
479
+ if (contrastRatio(preferred, fill) >= WAVE_LABEL_MIN_CONTRAST) return preferred;
480
+ return contrastRatio(wave.hatch, fill) >= contrastRatio(wave.hatchOnDark, fill)
481
+ ? wave.hatch
482
+ : wave.hatchOnDark;
483
+ }
484
+
485
+ /**
486
+ * One line at every distinct x in `{S_k} ∪ {E_k}` of the non-empty waves,
487
+ * except the origin (§9.2): contiguous waves share a line, a gap gives two,
488
+ * and an empty wave adds none of its own. A wave's opening line uses its
489
+ * own style; a closing line that opens nothing (a gap, or `E_n`) uses the
490
+ * closing wave's.
491
+ */
492
+ function buildBoundaries(
493
+ waves: PositionedWave[],
494
+ origin: number,
495
+ topY: number,
496
+ ctx: LayoutContext,
497
+ ): PositionedWaveBoundary[] {
498
+ const edges: Array<{ x: number; wave: PositionedWave; opening: boolean }> = [];
499
+ for (const w of waves) {
500
+ if (w.empty) continue;
501
+ edges.push({ x: w.startX, wave: w, opening: true });
502
+ edges.push({ x: w.endX, wave: w, opening: false });
503
+ }
504
+ edges.sort((a, b) => a.x - b.x);
505
+ const out: PositionedWaveBoundary[] = [];
506
+ let i = 0;
507
+ while (i < edges.length) {
508
+ let j = i;
509
+ while (j < edges.length && edges[j].x - edges[i].x < WAVE_EDGE_TOLERANCE_PX) j++;
510
+ const group = edges.slice(i, j);
511
+ i = j;
512
+ const x = group[0].x;
513
+ if (Math.abs(x - origin) < WAVE_EDGE_TOLERANCE_PX) continue;
514
+ const owner = (group.find((e) => e.opening) ?? group[0]).wave;
515
+ out.push({
516
+ x,
517
+ topY,
518
+ bottomY: ctx.swimlaneBottomY,
519
+ stroke: owner.style.boundary,
520
+ dash: owner.style.boundaryDash,
521
+ });
522
+ }
523
+ return out;
524
+ }
525
+
526
+ /** Background items (`waveRole: 'background'`) in lane and document order. */
527
+ function backgroundItems(lanes: PositionedSwimlane[]): PositionedItem[] {
528
+ const out: PositionedItem[] = [];
529
+ const walk = (children: PositionedTrackChild[]): void => {
530
+ for (const child of children) {
531
+ if (child.kind === 'item') {
532
+ if (child.waveRole === 'background') out.push(child);
533
+ } else {
534
+ walk(child.children);
535
+ }
536
+ }
537
+ };
538
+ for (const lane of lanes) {
539
+ walk(lane.children);
540
+ out.push(...backgroundItems(lane.nested));
541
+ }
542
+ return out;
543
+ }
544
+
545
+ /**
546
+ * A crossing for every boundary strictly inside a background bar's visual
547
+ * extent (§9.3). Item boxes are visual (inset from the logical edges), so a
548
+ * boundary at a bar's logical edge falls in the inset and gets none.
549
+ */
550
+ function crossingsOver(
551
+ items: PositionedItem[],
552
+ boundaries: PositionedWaveBoundary[],
553
+ ): PositionedWaveCrossing[] {
554
+ const out: PositionedWaveCrossing[] = [];
555
+ for (const item of items) {
556
+ const left = item.box.x;
557
+ const right = item.box.x + item.box.width;
558
+ for (const b of boundaries) {
559
+ if (left < b.x && b.x < right) {
560
+ out.push({
561
+ x: b.x,
562
+ topY: item.box.y,
563
+ bottomY: item.box.y + item.box.height,
564
+ stroke: b.stroke,
565
+ });
566
+ }
567
+ }
568
+ }
569
+ return out;
570
+ }
571
+
572
+ /**
573
+ * The wave legend (§9.5), or undefined without background work (which there
574
+ * is only when some wave is drawn): one hatch swatch, `Background work (not
575
+ * in a wave)`, `WAVE_LEGEND_LINE_PX` high from `topY` at the chart origin.
576
+ */
577
+ export function buildWaveLegend(
578
+ built: BuiltWaves,
579
+ ctx: LayoutContext,
580
+ strings: LocaleStrings,
581
+ topY: number,
582
+ ): PositionedWaveLegend | undefined {
583
+ if (!built.hasBackground) return undefined;
584
+ const left = ctx.timeline.originX;
585
+ const text = strings.waveLegendBackground;
586
+ const textX0 = WAVE_LEGEND_SWATCH_WIDTH_PX + WAVE_LEGEND_SWATCH_GAP_PX;
587
+ const width = textX0 + estimateTextWidth(text, WAVE_STRIP_LABEL_FONT_SIZE_PX);
588
+ const entry: WaveLegendEntry = {
589
+ kind: 'background',
590
+ text,
591
+ swatch: {
592
+ x: left,
593
+ y: topY + (WAVE_LEGEND_LINE_PX - WAVE_LEGEND_SWATCH_HEIGHT_PX) / 2,
594
+ width: WAVE_LEGEND_SWATCH_WIDTH_PX,
595
+ height: WAVE_LEGEND_SWATCH_HEIGHT_PX,
596
+ },
597
+ // The hatched swatch is a default item bar.
598
+ hatch: waveHatchKind(ctx.styleCtx.theme.entities.item.bg),
599
+ runs: [{ text, x: left + textX0, y: topY + WAVE_LEGEND_BASELINE_OFFSET_PX }],
600
+ };
601
+ return {
602
+ box: { x: left, y: topY, width, height: WAVE_LEGEND_LINE_PX },
603
+ entries: [entry],
604
+ };
605
+ }
606
+
607
+ function isoDay(d: Date): string {
608
+ return d.toISOString().slice(0, 10);
609
+ }
@@ -0,0 +1,32 @@
1
+ // How the chart shows a calendar's non-working days (specs/working-
2
+ // calendar.md §4.2): `hide` collapses them to zero width (the default),
3
+ // `show` draws them at full width as shaded bands. One shared definition
4
+ // for the layout and every surface that takes a render-time option, so the
5
+ // accepted spellings cannot drift between them. The table itself lives in
6
+ // core (`value-tables.ts`), which the validator and the editor completions
7
+ // read too; the GitHub Action still has its own copy, though it bundles
8
+ // layout's theme parser.
9
+
10
+ import { NON_WORKING_DISPLAYS, type NonWorkingDisplay } from '@nowline/core/internal';
11
+
12
+ /** A non-working display value. */
13
+ export type { NonWorkingDisplay };
14
+
15
+ /** Every display value, in documentation order. */
16
+ export { NON_WORKING_DISPLAYS };
17
+
18
+ /** True when `value` is a display value. */
19
+ export function isNonWorkingDisplay(value: unknown): value is NonWorkingDisplay {
20
+ return value === 'hide' || value === 'show';
21
+ }
22
+
23
+ /**
24
+ * Parse a raw surface option. Empty or missing input is "unset" and returns
25
+ * undefined, so the file's `default roadmap non-working:` key still applies;
26
+ * anything other than `hide` or `show` throws a `RangeError`.
27
+ */
28
+ export function parseNonWorkingDisplay(raw: string | undefined): NonWorkingDisplay | undefined {
29
+ if (raw === undefined || raw === '') return undefined;
30
+ if (isNonWorkingDisplay(raw)) return raw;
31
+ throw new RangeError(`Invalid non-working display "${raw}". Expected hide or show.`);
32
+ }