@nowline/layout 0.2.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 (183) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +103 -0
  3. package/dist/band-scale.d.ts +56 -0
  4. package/dist/band-scale.d.ts.map +1 -0
  5. package/dist/band-scale.js +86 -0
  6. package/dist/band-scale.js.map +1 -0
  7. package/dist/calendar.d.ts +79 -0
  8. package/dist/calendar.d.ts.map +1 -0
  9. package/dist/calendar.js +210 -0
  10. package/dist/calendar.js.map +1 -0
  11. package/dist/capacity.d.ts +72 -0
  12. package/dist/capacity.d.ts.map +1 -0
  13. package/dist/capacity.js +163 -0
  14. package/dist/capacity.js.map +1 -0
  15. package/dist/dsl-utils.d.ts +5 -0
  16. package/dist/dsl-utils.d.ts.map +1 -0
  17. package/dist/dsl-utils.js +28 -0
  18. package/dist/dsl-utils.js.map +1 -0
  19. package/dist/edge-routing.d.ts +89 -0
  20. package/dist/edge-routing.d.ts.map +1 -0
  21. package/dist/edge-routing.js +435 -0
  22. package/dist/edge-routing.js.map +1 -0
  23. package/dist/frame-tab-geometry.d.ts +78 -0
  24. package/dist/frame-tab-geometry.d.ts.map +1 -0
  25. package/dist/frame-tab-geometry.js +115 -0
  26. package/dist/frame-tab-geometry.js.map +1 -0
  27. package/dist/header-card-geometry.d.ts +29 -0
  28. package/dist/header-card-geometry.d.ts.map +1 -0
  29. package/dist/header-card-geometry.js +41 -0
  30. package/dist/header-card-geometry.js.map +1 -0
  31. package/dist/i18n.d.ts +48 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +114 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/include-chrome-geometry.d.ts +86 -0
  36. package/dist/include-chrome-geometry.d.ts.map +1 -0
  37. package/dist/include-chrome-geometry.js +104 -0
  38. package/dist/include-chrome-geometry.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +10 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/item-bar-geometry.d.ts +127 -0
  44. package/dist/item-bar-geometry.d.ts.map +1 -0
  45. package/dist/item-bar-geometry.js +173 -0
  46. package/dist/item-bar-geometry.js.map +1 -0
  47. package/dist/lane-utilization.d.ts +90 -0
  48. package/dist/lane-utilization.d.ts.map +1 -0
  49. package/dist/lane-utilization.js +206 -0
  50. package/dist/lane-utilization.js.map +1 -0
  51. package/dist/layout-context.d.ts +143 -0
  52. package/dist/layout-context.d.ts.map +1 -0
  53. package/dist/layout-context.js +8 -0
  54. package/dist/layout-context.js.map +1 -0
  55. package/dist/layout.d.ts +18 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +1213 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/nodes/anchor-node.d.ts +16 -0
  60. package/dist/nodes/anchor-node.d.ts.map +1 -0
  61. package/dist/nodes/anchor-node.js +68 -0
  62. package/dist/nodes/anchor-node.js.map +1 -0
  63. package/dist/nodes/footnote-node.d.ts +10 -0
  64. package/dist/nodes/footnote-node.d.ts.map +1 -0
  65. package/dist/nodes/footnote-node.js +41 -0
  66. package/dist/nodes/footnote-node.js.map +1 -0
  67. package/dist/nodes/group-node.d.ts +29 -0
  68. package/dist/nodes/group-node.d.ts.map +1 -0
  69. package/dist/nodes/group-node.js +187 -0
  70. package/dist/nodes/group-node.js.map +1 -0
  71. package/dist/nodes/include-node.d.ts +16 -0
  72. package/dist/nodes/include-node.d.ts.map +1 -0
  73. package/dist/nodes/include-node.js +117 -0
  74. package/dist/nodes/include-node.js.map +1 -0
  75. package/dist/nodes/item-node.d.ts +51 -0
  76. package/dist/nodes/item-node.d.ts.map +1 -0
  77. package/dist/nodes/item-node.js +108 -0
  78. package/dist/nodes/item-node.js.map +1 -0
  79. package/dist/nodes/marker-geometry.d.ts +22 -0
  80. package/dist/nodes/marker-geometry.d.ts.map +1 -0
  81. package/dist/nodes/marker-geometry.js +38 -0
  82. package/dist/nodes/marker-geometry.js.map +1 -0
  83. package/dist/nodes/milestone-node.d.ts +48 -0
  84. package/dist/nodes/milestone-node.d.ts.map +1 -0
  85. package/dist/nodes/milestone-node.js +210 -0
  86. package/dist/nodes/milestone-node.js.map +1 -0
  87. package/dist/nodes/parallel-node.d.ts +21 -0
  88. package/dist/nodes/parallel-node.d.ts.map +1 -0
  89. package/dist/nodes/parallel-node.js +80 -0
  90. package/dist/nodes/parallel-node.js.map +1 -0
  91. package/dist/nodes/roadmap-node.d.ts +76 -0
  92. package/dist/nodes/roadmap-node.d.ts.map +1 -0
  93. package/dist/nodes/roadmap-node.js +788 -0
  94. package/dist/nodes/roadmap-node.js.map +1 -0
  95. package/dist/nodes/swimlane-node.d.ts +38 -0
  96. package/dist/nodes/swimlane-node.d.ts.map +1 -0
  97. package/dist/nodes/swimlane-node.js +308 -0
  98. package/dist/nodes/swimlane-node.js.map +1 -0
  99. package/dist/renderable.d.ts +44 -0
  100. package/dist/renderable.d.ts.map +1 -0
  101. package/dist/renderable.js +21 -0
  102. package/dist/renderable.js.map +1 -0
  103. package/dist/row-packer.d.ts +125 -0
  104. package/dist/row-packer.d.ts.map +1 -0
  105. package/dist/row-packer.js +169 -0
  106. package/dist/row-packer.js.map +1 -0
  107. package/dist/style-resolution.d.ts +14 -0
  108. package/dist/style-resolution.d.ts.map +1 -0
  109. package/dist/style-resolution.js +191 -0
  110. package/dist/style-resolution.js.map +1 -0
  111. package/dist/themes/dark.d.ts +4 -0
  112. package/dist/themes/dark.d.ts.map +1 -0
  113. package/dist/themes/dark.js +241 -0
  114. package/dist/themes/dark.js.map +1 -0
  115. package/dist/themes/index.d.ts +15 -0
  116. package/dist/themes/index.d.ts.map +1 -0
  117. package/dist/themes/index.js +30 -0
  118. package/dist/themes/index.js.map +1 -0
  119. package/dist/themes/light.d.ts +4 -0
  120. package/dist/themes/light.d.ts.map +1 -0
  121. package/dist/themes/light.js +248 -0
  122. package/dist/themes/light.js.map +1 -0
  123. package/dist/themes/shape.d.ts +194 -0
  124. package/dist/themes/shape.d.ts.map +1 -0
  125. package/dist/themes/shape.js +6 -0
  126. package/dist/themes/shape.js.map +1 -0
  127. package/dist/themes/shared.d.ts +145 -0
  128. package/dist/themes/shared.d.ts.map +1 -0
  129. package/dist/themes/shared.js +310 -0
  130. package/dist/themes/shared.js.map +1 -0
  131. package/dist/time-scale.d.ts +39 -0
  132. package/dist/time-scale.d.ts.map +1 -0
  133. package/dist/time-scale.js +62 -0
  134. package/dist/time-scale.js.map +1 -0
  135. package/dist/types.d.ts +483 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +6 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/view-preset.d.ts +23 -0
  140. package/dist/view-preset.d.ts.map +1 -0
  141. package/dist/view-preset.js +146 -0
  142. package/dist/view-preset.js.map +1 -0
  143. package/dist/working-calendar.d.ts +14 -0
  144. package/dist/working-calendar.d.ts.map +1 -0
  145. package/dist/working-calendar.js +74 -0
  146. package/dist/working-calendar.js.map +1 -0
  147. package/package.json +37 -0
  148. package/src/band-scale.ts +115 -0
  149. package/src/calendar.ts +244 -0
  150. package/src/capacity.ts +191 -0
  151. package/src/dsl-utils.ts +30 -0
  152. package/src/edge-routing.ts +550 -0
  153. package/src/frame-tab-geometry.ts +165 -0
  154. package/src/header-card-geometry.ts +48 -0
  155. package/src/i18n.ts +124 -0
  156. package/src/include-chrome-geometry.ts +156 -0
  157. package/src/index.ts +116 -0
  158. package/src/item-bar-geometry.ts +222 -0
  159. package/src/lane-utilization.ts +259 -0
  160. package/src/layout-context.ts +182 -0
  161. package/src/layout.ts +1446 -0
  162. package/src/nodes/anchor-node.ts +77 -0
  163. package/src/nodes/footnote-node.ts +60 -0
  164. package/src/nodes/group-node.ts +252 -0
  165. package/src/nodes/include-node.ts +168 -0
  166. package/src/nodes/item-node.ts +171 -0
  167. package/src/nodes/marker-geometry.ts +43 -0
  168. package/src/nodes/milestone-node.ts +263 -0
  169. package/src/nodes/parallel-node.ts +101 -0
  170. package/src/nodes/roadmap-node.ts +957 -0
  171. package/src/nodes/swimlane-node.ts +423 -0
  172. package/src/renderable.ts +68 -0
  173. package/src/row-packer.ts +271 -0
  174. package/src/style-resolution.ts +243 -0
  175. package/src/themes/dark.ts +244 -0
  176. package/src/themes/index.ts +36 -0
  177. package/src/themes/light.ts +251 -0
  178. package/src/themes/shape.ts +230 -0
  179. package/src/themes/shared.ts +369 -0
  180. package/src/time-scale.ts +78 -0
  181. package/src/types.ts +607 -0
  182. package/src/view-preset.ts +180 -0
  183. package/src/working-calendar.ts +91 -0
@@ -0,0 +1,271 @@
1
+ // RowPacker — the topmost-fit row-pack engine shared by `SwimlaneNode`
2
+ // and `GroupNode`. Tracks rows of variable height (each row's height is
3
+ // the max of its placed children's heights, with a per-packer minimum)
4
+ // and bumps a child to a new row when it would collide with a sibling's
5
+ // `rightEdge`, caption `spillX`, or a slack-arrow corridor.
6
+ //
7
+ // Two-phase API per child:
8
+ // 1) `placeItem(...)` / `placeBlock(...)` decides which row the child
9
+ // lands on. The caller then sequences the child at the returned
10
+ // `(rowIndex, y)`, producing a fully positioned subtree.
11
+ // 2) `commitItem(...)` / `commitBlock(...)` records the placed child
12
+ // back into the packer so the row's `rightEdge` / `spillX` advance
13
+ // and the row's height grows to accommodate the new child.
14
+ //
15
+ // When a row's height grows after later rows already exist, the packer
16
+ // shifts every subsequent row (and every positioned child placed in it)
17
+ // downward by the delta. Items keep their row-anchored top edge; the
18
+ // row simply gets taller. This keeps the contract "all children in a
19
+ // row share that row's top y" intact while letting individual children
20
+ // grow vertically (e.g. wrapped label-chiclet stacks in m3).
21
+
22
+ import type { PositionedItem, PositionedTrackChild, SlackCorridor } from './types.js';
23
+
24
+ export interface PackedRow {
25
+ /** Top-y of the row in canvas px. */
26
+ y: number;
27
+ /** Visible height of the row. Grows with the tallest committed child. */
28
+ height: number;
29
+ /** Logical x of the rightmost extent reached by a committed child. */
30
+ rightEdge: number;
31
+ /** Reserved x for caption spill. Items whose `desiredStart < spillX`
32
+ * bump to a new row even if `rightEdge` would let them in. */
33
+ spillX: number;
34
+ /** Children placed in this row. Used when the row's height grows
35
+ * after later rows exist — every child in every later row shifts
36
+ * down by the delta. */
37
+ placedChildren: PositionedTrackChild[];
38
+ }
39
+
40
+ export interface RowPackerOptions {
41
+ /** Logical left x of the track this packer owns (lane left edge or
42
+ * group's content left). New rows reset their `rightEdge` /
43
+ * `spillX` to this value. */
44
+ laneLeftX: number;
45
+ /** Top-y of the first row. */
46
+ originY: number;
47
+ /** Minimum height of any row. Typically `bandScale.step()`. */
48
+ minRowHeight: number;
49
+ /** Slack-arrow corridors to avoid. Items whose vertical band
50
+ * intersects a corridor at the candidate row bump to a new row. */
51
+ slackCorridors: SlackCorridor[];
52
+ }
53
+
54
+ /** Item placement query — passed to `placeItem` BEFORE sequencing. */
55
+ export interface ItemPlacementInput {
56
+ /** Item id; corridor-bumping is skipped for the corridor's own
57
+ * predecessor (the slack arrow's source item is exempt). */
58
+ childId: string;
59
+ /** Logical left x where the item wants to start. */
60
+ desiredStart: number;
61
+ /** Logical right x of the item's natural extent. */
62
+ desiredEnd: number;
63
+ /** Predicted intrinsic height of the item. Used to set the row's
64
+ * initial height when this is the first item in a fresh row, or
65
+ * to grow the row when a taller item lands in an existing row. */
66
+ predictedHeight: number;
67
+ }
68
+
69
+ /** Result of a `placeItem` query. */
70
+ export interface ItemPlacement {
71
+ rowIndex: number;
72
+ y: number;
73
+ }
74
+
75
+ /** Item-commit payload — passed to `commitItem` AFTER sequencing. */
76
+ export interface ItemCommitInput {
77
+ rowIndex: number;
78
+ placed: PositionedItem;
79
+ /** Logical right edge of the placed item. Includes the trailing
80
+ * `ITEM_INSET_PX` so the next chained item butts edge-to-edge in
81
+ * logical space (with a 2 × ITEM_INSET_PX visible gutter between
82
+ * bars). */
83
+ logicalEnd: number;
84
+ /** Reserved spill x for captions that overflow the bar's right
85
+ * edge. `null` resets the row's spill reservation to `laneLeftX`. */
86
+ spillReservation: number | null;
87
+ /** Total vertical reservation the placed item needs (e.g. bar
88
+ * height plus a multi-row spilled chip column hanging below the
89
+ * bar). When omitted, the row only grows to `placed.box.height`,
90
+ * which is fine for items whose entire content fits inside the
91
+ * bar. Set this to `predictedHeight` from `placeItem(...)` when
92
+ * the item has a chip overhang so the row grows even if it
93
+ * landed in an EXISTING row (where the placement step ignores
94
+ * `predictedHeight`). */
95
+ rowHeight?: number;
96
+ }
97
+
98
+ /** Result of a `placeBlock` query (parallel/group as a child). */
99
+ export interface BlockPlacement {
100
+ rowIndex: number;
101
+ y: number;
102
+ }
103
+
104
+ /** Block-commit payload — passed to `commitBlock` AFTER sequencing. */
105
+ export interface BlockCommitInput {
106
+ rowIndex: number;
107
+ placed: PositionedTrackChild;
108
+ /** Total vertical extent the block occupies (the block's reported
109
+ * `box.height`). The row this block landed on grows to at least
110
+ * this height. */
111
+ blockHeight: number;
112
+ /** Logical right edge of the block. */
113
+ blockEnd: number;
114
+ }
115
+
116
+ export class RowPacker {
117
+ public readonly rows: PackedRow[];
118
+ private readonly opts: RowPackerOptions;
119
+
120
+ constructor(opts: RowPackerOptions) {
121
+ this.opts = opts;
122
+ this.rows = [
123
+ {
124
+ y: opts.originY,
125
+ height: opts.minRowHeight,
126
+ rightEdge: opts.laneLeftX,
127
+ spillX: opts.laneLeftX,
128
+ placedChildren: [],
129
+ },
130
+ ];
131
+ }
132
+
133
+ /**
134
+ * Find the topmost row where the item's natural extent fits without
135
+ * colliding with sibling content or a slack corridor; appending a
136
+ * fresh row at the bottom when none of the existing rows fit.
137
+ */
138
+ placeItem(input: ItemPlacementInput): ItemPlacement {
139
+ for (let i = 0; i < this.rows.length; i += 1) {
140
+ const r = this.rows[i];
141
+ if (input.desiredStart < r.rightEdge) continue;
142
+ if (input.desiredStart < r.spillX) continue;
143
+ if (this.rowIntersectsCorridor(r, input)) continue;
144
+ return { rowIndex: i, y: r.y };
145
+ }
146
+ const fresh = this.appendRow(Math.max(this.opts.minRowHeight, input.predictedHeight));
147
+ return { rowIndex: this.rows.length - 1, y: fresh.y };
148
+ }
149
+
150
+ /**
151
+ * Blocks (parallel / group) always claim a fresh row at the bottom
152
+ * of the stack so their inner sub-tracks have contiguous rows to
153
+ * expand into. If the bottom row is empty, we reuse it; otherwise
154
+ * we append.
155
+ */
156
+ placeBlock(): BlockPlacement {
157
+ const last = this.rows[this.rows.length - 1];
158
+ if (last.rightEdge > this.opts.laneLeftX) {
159
+ const fresh = this.appendRow(this.opts.minRowHeight);
160
+ return { rowIndex: this.rows.length - 1, y: fresh.y };
161
+ }
162
+ return { rowIndex: this.rows.length - 1, y: last.y };
163
+ }
164
+
165
+ /**
166
+ * Record a placed item back into its row. Grows the row's height to
167
+ * fit the item, advances `rightEdge`, sets the caption spill, and
168
+ * shifts later rows down if this row got taller.
169
+ */
170
+ commitItem(input: ItemCommitInput): void {
171
+ const row = this.rows[input.rowIndex];
172
+ const target = Math.max(input.placed.box.height, input.rowHeight ?? 0);
173
+ this.growRowHeight(input.rowIndex, target);
174
+ row.rightEdge = input.logicalEnd;
175
+ row.spillX = input.spillReservation ?? this.opts.laneLeftX;
176
+ row.placedChildren.push(input.placed);
177
+ }
178
+
179
+ /**
180
+ * Record a placed block. Sets the row's height to the block's full
181
+ * height (blocks own their row, no other child shares it).
182
+ */
183
+ commitBlock(input: BlockCommitInput): void {
184
+ const row = this.rows[input.rowIndex];
185
+ this.growRowHeight(input.rowIndex, input.blockHeight);
186
+ row.rightEdge = input.blockEnd;
187
+ row.spillX = this.opts.laneLeftX;
188
+ row.placedChildren.push(input.placed);
189
+ }
190
+
191
+ /** Total vertical extent reached (last row's bottom - originY). */
192
+ usedHeight(): number {
193
+ const last = this.rows[this.rows.length - 1];
194
+ return last.y + last.height - this.opts.originY;
195
+ }
196
+
197
+ /** Rightmost extent reached — bar logical end OR caption spill,
198
+ * whichever is wider, across every row. Empty rows contribute
199
+ * `laneLeftX`. */
200
+ usedRightX(): number {
201
+ let x = this.opts.laneLeftX;
202
+ for (const r of this.rows) {
203
+ if (r.rightEdge > x) x = r.rightEdge;
204
+ if (r.spillX > x) x = r.spillX;
205
+ }
206
+ return x;
207
+ }
208
+
209
+ private rowIntersectsCorridor(r: PackedRow, p: ItemPlacementInput): boolean {
210
+ return this.opts.slackCorridors.some(
211
+ (c) =>
212
+ c.y >= r.y &&
213
+ c.y < r.y + r.height &&
214
+ p.desiredStart < c.xEnd &&
215
+ p.desiredEnd > c.xStart &&
216
+ c.slackPredId !== p.childId,
217
+ );
218
+ }
219
+
220
+ private appendRow(height: number): PackedRow {
221
+ const last = this.rows[this.rows.length - 1];
222
+ const r: PackedRow = {
223
+ y: last.y + last.height,
224
+ height,
225
+ rightEdge: this.opts.laneLeftX,
226
+ spillX: this.opts.laneLeftX,
227
+ placedChildren: [],
228
+ };
229
+ this.rows.push(r);
230
+ return r;
231
+ }
232
+
233
+ private growRowHeight(rowIndex: number, newHeight: number): void {
234
+ const row = this.rows[rowIndex];
235
+ const delta = newHeight - row.height;
236
+ if (delta <= 0) return;
237
+ row.height = newHeight;
238
+ for (let i = rowIndex + 1; i < this.rows.length; i += 1) {
239
+ const r = this.rows[i];
240
+ r.y += delta;
241
+ for (const child of r.placedChildren) {
242
+ shiftPositionedY(child, delta);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Walk a positioned subtree and shift every absolute y by `dy`. Used
250
+ * when an earlier row in the packer grows, retroactively pushing every
251
+ * later row (and its placed subtrees) down to keep them clear.
252
+ *
253
+ * Items: shift the bar box plus label chips and any overflow box.
254
+ * Groups / parallels: shift the container box and recurse into
255
+ * children. The caller's `box.y` already moved with the parent.
256
+ */
257
+ function shiftPositionedY(p: PositionedTrackChild, dy: number): void {
258
+ p.box.y += dy;
259
+ if (p.kind === 'item') {
260
+ for (const chip of p.labelChips) {
261
+ chip.box.y += dy;
262
+ }
263
+ if (p.overflowBox) {
264
+ p.overflowBox.y += dy;
265
+ }
266
+ return;
267
+ }
268
+ for (const child of p.children) {
269
+ shiftPositionedY(child, dy);
270
+ }
271
+ }
@@ -0,0 +1,243 @@
1
+ import type {
2
+ DefaultDeclaration,
3
+ EntityProperty,
4
+ LabelDeclaration,
5
+ StyleDeclaration,
6
+ StyleProperty,
7
+ } from '@nowline/core';
8
+ import { resolveColor } from './themes/index.js';
9
+ import type { EntityStyle, Theme } from './themes/shape.js';
10
+ import type { ResolvedStyle, SizeBucket } from './types.js';
11
+
12
+ type EntityTypeKey =
13
+ | 'roadmap'
14
+ | 'swimlane'
15
+ | 'item'
16
+ | 'parallel'
17
+ | 'group'
18
+ | 'anchor'
19
+ | 'milestone'
20
+ | 'footnote'
21
+ | 'label';
22
+
23
+ export interface StyleContext {
24
+ theme: Theme;
25
+ // Map of style id → StyleDeclaration (from resolveIncludes().config.styles
26
+ // or a fallback scan of the parent file).
27
+ styles: Map<string, StyleDeclaration>;
28
+ // Map of `default <entity>` per entity type. May be missing entries.
29
+ defaults: Map<string, DefaultDeclaration>;
30
+ // Map of label id → LabelDeclaration for resolving label styles.
31
+ labels: Map<string, LabelDeclaration>;
32
+ }
33
+
34
+ function propKey(prop: { key: string }): string {
35
+ return prop.key.endsWith(':') ? prop.key.slice(0, -1) : prop.key;
36
+ }
37
+
38
+ function entityStyleToResolved(e: EntityStyle, theme: Theme): ResolvedStyle {
39
+ return {
40
+ bg: resolveColor(e.bg, theme),
41
+ fg: resolveColor(e.fg, theme),
42
+ text: resolveColor(e.text, theme),
43
+ border: e.border,
44
+ icon: e.icon,
45
+ shadow: e.shadow,
46
+ font: e.font,
47
+ weight: e.weight,
48
+ italic: e.italic,
49
+ textSize: e.textSize,
50
+ padding: e.padding,
51
+ spacing: e.spacing,
52
+ headerHeight: e.headerHeight,
53
+ cornerRadius: e.cornerRadius,
54
+ bracket: e.bracket,
55
+ // DSL does not expose header-position on non-roadmap entities; system
56
+ // default `beside` applies. Roadmap entity's resolve step lifts this
57
+ // from the 5-level chain.
58
+ headerPosition: 'beside',
59
+ capacityIcon: e.capacityIcon,
60
+ // Roadmap-only readability knobs. Defaults preserve the existing
61
+ // single-top-strip layout and keep the major-ticks-only grid.
62
+ timelinePosition: 'top',
63
+ minorGrid: false,
64
+ };
65
+ }
66
+
67
+ // Apply a single style property (from `style` blocks, `default` blocks, or
68
+ // label styles) onto an accumulating ResolvedStyle.
69
+ function applyProp(target: ResolvedStyle, key: string, value: string, theme: Theme): void {
70
+ switch (key) {
71
+ case 'bg':
72
+ target.bg = resolveColor(value, theme);
73
+ break;
74
+ case 'fg':
75
+ target.fg = resolveColor(value, theme);
76
+ break;
77
+ case 'text':
78
+ target.text = resolveColor(value, theme);
79
+ break;
80
+ case 'border':
81
+ if (value === 'solid' || value === 'dashed' || value === 'dotted')
82
+ target.border = value;
83
+ break;
84
+ case 'icon':
85
+ target.icon = value;
86
+ break;
87
+ case 'shadow':
88
+ if (value === 'none' || value === 'subtle' || value === 'soft' || value === 'hard') {
89
+ target.shadow = value;
90
+ }
91
+ break;
92
+ case 'font':
93
+ if (value === 'sans' || value === 'serif' || value === 'mono') target.font = value;
94
+ break;
95
+ case 'weight':
96
+ if (value === 'thin' || value === 'light' || value === 'normal' || value === 'bold') {
97
+ target.weight = value;
98
+ }
99
+ break;
100
+ case 'italic':
101
+ target.italic = value === 'true';
102
+ break;
103
+ case 'text-size':
104
+ target.textSize = coerceSize(value, 'xl') as ResolvedStyle['textSize'];
105
+ break;
106
+ case 'padding':
107
+ target.padding = coerceSize(value, 'xl') as ResolvedStyle['padding'];
108
+ break;
109
+ case 'spacing':
110
+ target.spacing = coerceSize(value, 'xl') as ResolvedStyle['spacing'];
111
+ break;
112
+ case 'header-height':
113
+ target.headerHeight = coerceSize(value, 'xl') as ResolvedStyle['headerHeight'];
114
+ break;
115
+ case 'corner-radius':
116
+ target.cornerRadius = coerceSize(value, 'full') as ResolvedStyle['cornerRadius'];
117
+ break;
118
+ case 'bracket':
119
+ if (value === 'none' || value === 'solid' || value === 'dashed') target.bracket = value;
120
+ break;
121
+ case 'header-position':
122
+ if (value === 'beside' || value === 'above') target.headerPosition = value;
123
+ break;
124
+ case 'capacity-icon':
125
+ // Validator (rule 17e + checkSymbolReferences) has already verified
126
+ // the value is a built-in name, a declared symbol id, or an inline
127
+ // Unicode literal. Pass it through verbatim — interpretation
128
+ // happens in the renderer where we have access to ResolvedConfig.symbols.
129
+ target.capacityIcon = value;
130
+ break;
131
+ case 'timeline-position':
132
+ if (value === 'top' || value === 'bottom' || value === 'both') {
133
+ target.timelinePosition = value;
134
+ }
135
+ break;
136
+ case 'minor-grid':
137
+ target.minorGrid = value === 'true';
138
+ break;
139
+ default:
140
+ break;
141
+ }
142
+ }
143
+
144
+ function coerceSize(value: string, max: SizeBucket): SizeBucket {
145
+ const order: SizeBucket[] = ['none', 'xs', 'sm', 'md', 'lg', 'xl', 'full'];
146
+ const cap = order.indexOf(max);
147
+ const idx = order.indexOf(value as SizeBucket);
148
+ if (idx < 0) return 'md';
149
+ if (idx > cap) return order[cap];
150
+ return order[idx];
151
+ }
152
+
153
+ function applyStyleDecl(
154
+ target: ResolvedStyle,
155
+ decl: StyleDeclaration | undefined,
156
+ theme: Theme,
157
+ ): void {
158
+ if (!decl) return;
159
+ for (const p of decl.properties as StyleProperty[]) {
160
+ applyProp(target, propKey(p), p.value, theme);
161
+ }
162
+ }
163
+
164
+ function applyProperties(target: ResolvedStyle, props: EntityProperty[], theme: Theme): void {
165
+ for (const p of props) {
166
+ const key = propKey(p);
167
+ if (p.value !== undefined) {
168
+ applyProp(target, key, p.value, theme);
169
+ }
170
+ }
171
+ }
172
+
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
+ // Five-level precedence chain from specs/rendering.md § Style Precedence:
203
+ // 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)
209
+ export function resolveStyle(
210
+ entityType: EntityTypeKey,
211
+ props: EntityProperty[],
212
+ ctx: StyleContext,
213
+ ): ResolvedStyle {
214
+ const baseEntity = ctx.theme.entities[entityType];
215
+ 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
+ }
224
+ }
225
+ }
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
+ return out;
237
+ }
238
+
239
+ // Resolve a label's *display* style (bg/fg/text + bracket etc.) as used on
240
+ // label chips rendered on items. The chip uses the label entity type.
241
+ export function resolveLabelChipStyle(label: LabelDeclaration, ctx: StyleContext): ResolvedStyle {
242
+ return resolveStyle('label', label.properties, ctx);
243
+ }