@nowline/layout 0.2.5 → 0.4.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.
@@ -0,0 +1,196 @@
1
+ // Inline-date pin glyph placement.
2
+ //
3
+ // `after:DATE` paints a calendar glyph in the entity's top-LEFT decoration
4
+ // slot; `before:DATE` paints it in the top-RIGHT slot. The two helpers
5
+ // below produce `Point` coordinates for the glyph's top-left corner, plus
6
+ // a `spilled` flag indicating whether the bar was too narrow to host the
7
+ // glyph inside (item case only — containers always have room).
8
+ //
9
+ // Slot interleaving rules (per specs/rendering.md "Inline-date glyph"):
10
+ //
11
+ // Item top-LEFT: glyph sits at the bar's leftmost slot when no link icon
12
+ // is present, otherwise one decoration step right of the link icon's
13
+ // right edge.
14
+ //
15
+ // Item top-RIGHT: glyph sits at the bar's rightmost slot when no status
16
+ // dot or footnotes are present, one step LEFT of the status dot when no
17
+ // footnotes, and one step LEFT of the LEFTMOST footnote indicator when
18
+ // footnotes are present (so the inline-date glyph inserts at the LEFT
19
+ // end of the existing badge cluster rather than reordering it).
20
+ //
21
+ // Container (group, parallel): glyph sits at the bounding box's top
22
+ // corners with the standard inset. Containers don't carry status dots
23
+ // or footnote indicators in their own decoration row, so no
24
+ // interleaving math is needed.
25
+
26
+ import {
27
+ INLINE_DATE_GLYPH_GAP_PX,
28
+ INLINE_DATE_GLYPH_INSET_LEFT_PX,
29
+ INLINE_DATE_GLYPH_INSET_RIGHT_PX,
30
+ INLINE_DATE_GLYPH_INSET_TOP_PX,
31
+ INLINE_DATE_GLYPH_TILE_SIZE_PX,
32
+ ITEM_CAPTION_SPILL_GAP_PX,
33
+ ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
34
+ ITEM_FOOTNOTE_INDICATOR_STEP_PX,
35
+ ITEM_LINK_ICON_INSET_PX,
36
+ ITEM_LINK_ICON_TILE_SIZE_PX,
37
+ ITEM_STATUS_DOT_INSET_RIGHT_PX,
38
+ ITEM_STATUS_DOT_RADIUS_PX,
39
+ MIN_BAR_WIDTH_FOR_INLINE_DATE_PX,
40
+ } from './item-bar-geometry.js';
41
+ import type { BoundingBox, InlineDatePin, Point } from './types.js';
42
+
43
+ export interface ItemInlineDatePinInputs {
44
+ box: BoundingBox;
45
+ /** ISO date string from `after:DATE`, or undefined when no inline `after`. */
46
+ afterDate: string | undefined;
47
+ /** ISO date string from `before:DATE`, or undefined when no inline `before`. */
48
+ beforeDate: string | undefined;
49
+ hasLinkIcon: boolean;
50
+ /** Number of footnote indicators rendered in the bar's top-RIGHT cluster. */
51
+ footnoteCount: number;
52
+ }
53
+
54
+ /**
55
+ * Compute inline-date pin glyph placements for an item bar. Returns an
56
+ * empty array when neither `afterDate` nor `beforeDate` is set.
57
+ *
58
+ * Item bars participate in the narrow-bar spill family — when the bar is
59
+ * narrower than `MIN_BAR_WIDTH_FOR_INLINE_DATE_PX`, the `before:` glyph
60
+ * spills RIGHT of the bar (joining the status-dot / footnote spill
61
+ * column) and the `after:` glyph spills LEFT of the bar's leading edge
62
+ * so the side semantics stay readable.
63
+ */
64
+ export function computeItemInlineDatePins(opts: ItemInlineDatePinInputs): InlineDatePin[] {
65
+ const { box, afterDate, beforeDate, hasLinkIcon, footnoteCount } = opts;
66
+ if (!afterDate && !beforeDate) return [];
67
+
68
+ const pins: InlineDatePin[] = [];
69
+ const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
70
+ const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
71
+ const spilled = box.width < MIN_BAR_WIDTH_FOR_INLINE_DATE_PX;
72
+
73
+ if (afterDate) {
74
+ const insideLeftX = hasLinkIcon
75
+ ? box.x +
76
+ ITEM_LINK_ICON_INSET_PX +
77
+ ITEM_LINK_ICON_TILE_SIZE_PX +
78
+ INLINE_DATE_GLYPH_GAP_PX
79
+ : box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX;
80
+ const glyphLeft: Point = spilled
81
+ ? {
82
+ x: box.x - ITEM_CAPTION_SPILL_GAP_PX - tileSize,
83
+ y: topY,
84
+ }
85
+ : { x: insideLeftX, y: topY };
86
+ pins.push({
87
+ side: 'after',
88
+ isoDate: afterDate,
89
+ glyphTopLeft: glyphLeft,
90
+ glyphSize: tileSize,
91
+ spilled,
92
+ });
93
+ }
94
+
95
+ if (beforeDate) {
96
+ // Walk LEFT from the rightmost top-decoration slot:
97
+ // - rightmost footnote anchors at (box.right - INSET_RIGHT_PX)
98
+ // - leftmost footnote sits one step further left per extra digit
99
+ // - status dot left edge sits at (box.right - INSET_RIGHT - DOT_RADIUS)
100
+ // - inline-date glyph sits one INLINE_DATE_GLYPH_GAP_PX further left
101
+ const rightEdge = box.x + box.width;
102
+ let anchorRightX: number;
103
+ if (footnoteCount > 0) {
104
+ const leftmostFootnoteCenter =
105
+ rightEdge -
106
+ ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX -
107
+ (footnoteCount - 1) * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
108
+ anchorRightX = leftmostFootnoteCenter - INLINE_DATE_GLYPH_GAP_PX;
109
+ } else {
110
+ const dotLeftEdge =
111
+ rightEdge - ITEM_STATUS_DOT_INSET_RIGHT_PX - ITEM_STATUS_DOT_RADIUS_PX;
112
+ anchorRightX = dotLeftEdge - INLINE_DATE_GLYPH_GAP_PX;
113
+ }
114
+ const insideRightX = anchorRightX - tileSize;
115
+ const glyphLeft: Point = spilled
116
+ ? {
117
+ x: rightEdge + ITEM_CAPTION_SPILL_GAP_PX,
118
+ y: topY,
119
+ }
120
+ : { x: insideRightX, y: topY };
121
+ pins.push({
122
+ side: 'before',
123
+ isoDate: beforeDate,
124
+ glyphTopLeft: glyphLeft,
125
+ glyphSize: tileSize,
126
+ spilled,
127
+ });
128
+ }
129
+
130
+ return pins;
131
+ }
132
+
133
+ export interface ContainerInlineDatePinInputs {
134
+ /** Bounding box that anchors the glyph corners. For styled groups and
135
+ * bracketed parallels this is the visible box; for unstyled groups
136
+ * and bare parallels it is the logical bounding box (leftmost child
137
+ * start, rightmost child end, top of the highest child row). */
138
+ box: BoundingBox;
139
+ afterDate: string | undefined;
140
+ beforeDate: string | undefined;
141
+ }
142
+
143
+ /**
144
+ * Compute inline-date pin glyph placements for a container (group or
145
+ * parallel). The glyphs sit flush to the box's top-LEFT (`after`) and
146
+ * top-RIGHT (`before`) corners with the standard inset; containers
147
+ * never spill (they always have room for a 12 px tile in their own
148
+ * top-decoration row).
149
+ */
150
+ export function computeContainerInlineDatePins(
151
+ opts: ContainerInlineDatePinInputs,
152
+ ): InlineDatePin[] {
153
+ const { box, afterDate, beforeDate } = opts;
154
+ if (!afterDate && !beforeDate) return [];
155
+
156
+ const pins: InlineDatePin[] = [];
157
+ const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
158
+ const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
159
+
160
+ if (afterDate) {
161
+ pins.push({
162
+ side: 'after',
163
+ isoDate: afterDate,
164
+ glyphTopLeft: { x: box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX, y: topY },
165
+ glyphSize: tileSize,
166
+ spilled: false,
167
+ });
168
+ }
169
+
170
+ if (beforeDate) {
171
+ pins.push({
172
+ side: 'before',
173
+ isoDate: beforeDate,
174
+ glyphTopLeft: {
175
+ x: box.x + box.width - INLINE_DATE_GLYPH_INSET_RIGHT_PX - tileSize,
176
+ y: topY,
177
+ },
178
+ glyphSize: tileSize,
179
+ spilled: false,
180
+ });
181
+ }
182
+
183
+ return pins;
184
+ }
185
+
186
+ /**
187
+ * Returns the first ISO date literal in `values`, or undefined when none
188
+ * is present. The validator enforces "at most one inline date per
189
+ * direction" so this lookup is unambiguous.
190
+ */
191
+ export function pickInlineDate(values: readonly string[]): string | undefined {
192
+ for (const v of values) {
193
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
194
+ }
195
+ return undefined;
196
+ }
@@ -116,6 +116,55 @@ export const MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX =
116
116
  */
117
117
  export const MIN_BAR_WIDTH_FOR_FOOTNOTE_PX = ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX + 1;
118
118
 
119
+ // ---- Inline-date glyph (after:DATE / before:DATE corner badge) ---
120
+ //
121
+ // Renders the renderer's built-in `calendar` SVG at top-LEFT (`after`)
122
+ // or top-RIGHT (`before`) of the entity bar. Sized as a sibling of
123
+ // the status dot and footnote indicators (smaller than the link tile)
124
+ // so the top-decoration row stays visually unified. See
125
+ // specs/rendering.md "Inline-date glyph".
126
+
127
+ /** Side length (px) of the inline-date calendar glyph tile. */
128
+ export const INLINE_DATE_GLYPH_TILE_SIZE_PX = 12;
129
+
130
+ /** Distance (px) from the bar's left edge to the glyph's left edge
131
+ * when the glyph sits at the leftmost top-decoration slot (no link
132
+ * icon present). */
133
+ export const INLINE_DATE_GLYPH_INSET_LEFT_PX = 6;
134
+
135
+ /** Distance (px) from the bar's right edge to the glyph's right edge
136
+ * when the glyph sits at the rightmost top-decoration slot (no
137
+ * status dot or footnote present). */
138
+ export const INLINE_DATE_GLYPH_INSET_RIGHT_PX = 6;
139
+
140
+ /** Distance (px) from the bar's top edge to the glyph's top edge. */
141
+ export const INLINE_DATE_GLYPH_INSET_TOP_PX = 5;
142
+
143
+ /** Horizontal gap (px) between the inline-date glyph and an adjacent
144
+ * decoration (link icon on the left, status dot / footnote indicator
145
+ * on the right). Matches the existing decoration spill gap so the
146
+ * top-decoration row reads as one rhythm. */
147
+ export const INLINE_DATE_GLYPH_GAP_PX = ITEM_DECORATION_SPILL_GAP_PX;
148
+
149
+ /**
150
+ * Minimum bar width (px) needed to host the inline-date glyph inside
151
+ * the bar. Below this, the glyph spills:
152
+ * - `before:` glyph spills RIGHT into the column the status dot
153
+ * uses (same family of decorations, same step pitch).
154
+ * - `after:` glyph spills LEFT of the bar's leading edge so the
155
+ * side semantics (after vs before) stay readable in the spill.
156
+ *
157
+ * The threshold reserves room for the glyph itself plus a small
158
+ * clearance from the link-icon column (when present) so the two
159
+ * glyphs don't overlap inside narrow bars.
160
+ */
161
+ export const MIN_BAR_WIDTH_FOR_INLINE_DATE_PX =
162
+ INLINE_DATE_GLYPH_INSET_LEFT_PX +
163
+ INLINE_DATE_GLYPH_TILE_SIZE_PX +
164
+ INLINE_DATE_GLYPH_GAP_PX +
165
+ INLINE_DATE_GLYPH_TILE_SIZE_PX +
166
+ INLINE_DATE_GLYPH_INSET_RIGHT_PX;
167
+
119
168
  // ---- Label chips (along the bar's bottom) ------------------------
120
169
 
121
170
  /** Height (px) of a label chip rectangle. */
package/src/layout.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  HEADER_TITLE_TO_AUTHOR_GAP_PX,
41
41
  } from './header-card-geometry.js';
42
42
  import { localeStrings } from './i18n.js';
43
+ import { computeItemInlineDatePins, pickInlineDate } from './inline-date-pin-geometry.js';
43
44
  import {
44
45
  ITEM_CAPTION_INSET_X_PX,
45
46
  ITEM_CAPTION_META_BASELINE_OFFSET_PX,
@@ -226,11 +227,15 @@ function sequenceItem(
226
227
  // "one engineer-week of work left".
227
228
  const totalEffortDays = deriveTotalEffortDays(props, ctx.sizes, ctx.cal);
228
229
  const afterRaw = propValues(props, 'after');
229
- const beforeRaw = propValue(props, 'before');
230
+ const beforeRaw = propValues(props, 'before');
230
231
  const dateRaw = propValue(props, 'date');
231
232
  const remainingDays = resolveDuration(propValue(props, 'remaining'), ctx.sizes, ctx.cal);
232
233
 
233
- // Resolve start x: explicit date > after-chain > cursor position
234
+ // Resolve start x: explicit date > after-chain > cursor position.
235
+ // `after:` accepts both entity ids (looked up in entityRightEdges) and
236
+ // inline ISO date literals (resolved through the time scale). The
237
+ // validator enforces "at most one inline date per direction" so at most
238
+ // one element will hit the date path per item.
234
239
  let startX = cursor.x;
235
240
  const explicitDate = parseDate(dateRaw);
236
241
  if (explicitDate) {
@@ -239,6 +244,12 @@ function sequenceItem(
239
244
  } else if (afterRaw.length > 0) {
240
245
  let maxEnd = cursor.x;
241
246
  for (const ref of afterRaw) {
247
+ const inlineDate = parseDate(ref);
248
+ if (inlineDate) {
249
+ const xd = ctx.scale.forwardWithinDomain(inlineDate);
250
+ if (xd !== null) maxEnd = Math.max(maxEnd, xd);
251
+ continue;
252
+ }
242
253
  const endX = ctx.entityRightEdges.get(ref);
243
254
  if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
244
255
  }
@@ -286,24 +297,38 @@ function sequenceItem(
286
297
  const chipInsideAvailWidth = Math.max(0, visualWidthPredict - 2 * ITEM_CAPTION_INSET_X_PX);
287
298
  const chipsOutside = chipSamples.length > 0 && chipRowWidth > chipInsideAvailWidth;
288
299
 
289
- // Handle `before:` — item must end by the named anchor/milestone x.
300
+ // Handle `before:` — item must end by the earliest cap. Each cap is
301
+ // either an entity id (looked up in entityLeftEdges) or an inline ISO
302
+ // date literal (resolved through the time scale). The validator enforces
303
+ // "at most one inline date per direction" so at most one element per
304
+ // before-list will hit the date path.
290
305
  let hasOverflow = false;
291
306
  let overflowBox: BoundingBox | undefined;
292
307
  let overflowAnchorId: string | undefined;
293
- if (beforeRaw) {
294
- const beforeX = ctx.entityLeftEdges.get(beforeRaw);
295
- if (beforeX !== undefined) {
296
- if (logicalRight > beforeX) {
297
- hasOverflow = true;
298
- overflowBox = {
299
- x: beforeX,
300
- y: cursor.y,
301
- width: logicalRight - beforeX,
302
- height: ctx.bandScale.bandwidth(),
303
- };
304
- overflowAnchorId = beforeRaw;
308
+ if (beforeRaw.length > 0) {
309
+ let earliestCapX: number | undefined;
310
+ let earliestCapRef: string | undefined;
311
+ for (const ref of beforeRaw) {
312
+ const inlineDate = parseDate(ref);
313
+ const capX = inlineDate
314
+ ? (ctx.scale.forwardWithinDomain(inlineDate) ?? undefined)
315
+ : ctx.entityLeftEdges.get(ref);
316
+ if (capX === undefined) continue;
317
+ if (earliestCapX === undefined || capX < earliestCapX) {
318
+ earliestCapX = capX;
319
+ earliestCapRef = ref;
305
320
  }
306
321
  }
322
+ if (earliestCapX !== undefined && logicalRight > earliestCapX) {
323
+ hasOverflow = true;
324
+ overflowBox = {
325
+ x: earliestCapX,
326
+ y: cursor.y,
327
+ width: logicalRight - earliestCapX,
328
+ height: ctx.bandScale.bandwidth(),
329
+ };
330
+ overflowAnchorId = earliestCapRef;
331
+ }
307
332
  }
308
333
 
309
334
  // Progress fraction
@@ -695,6 +720,14 @@ function sequenceItem(
695
720
  // `step − bandwidth` px below the (now-taller) bar bottom.
696
721
  cursor.height = Math.max(cursor.height, ctx.bandScale.step() + chipBarExtra);
697
722
 
723
+ const inlineDatePins = computeItemInlineDatePins({
724
+ box: itemBox,
725
+ afterDate: pickInlineDate(afterRaw),
726
+ beforeDate: pickInlineDate(beforeRaw),
727
+ hasLinkIcon,
728
+ footnoteCount: footnoteIndicators.length,
729
+ });
730
+
698
731
  const result: PositionedItem = {
699
732
  kind: 'item',
700
733
  id,
@@ -725,6 +758,7 @@ function sequenceItem(
725
758
  capacity,
726
759
  size: sizeResolved,
727
760
  style,
761
+ inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
728
762
  };
729
763
  return result;
730
764
  }
@@ -895,6 +929,11 @@ function predictItemChipExtraHeight(item: ItemDeclaration, ctx: LayoutContext):
895
929
  // pin) > `start:` (fixed pin) > `after:` (chain after refs) > sequential
896
930
  // default (continue from `seqDefault`, which is the lane's rightmost time
897
931
  // cursor across all rows).
932
+ //
933
+ // `after:` accepts both entity ids (looked up in `ctx.entityRightEdges`) and
934
+ // inline ISO date literals (looked up via `ctx.scale.forwardWithinDomain`).
935
+ // The validator already enforces "at most one inline date per direction" so
936
+ // at most one element in the list will hit the date path.
898
937
  function resolveChildStart(
899
938
  props: EntityProperty[],
900
939
  seqDefault: number,
@@ -911,6 +950,12 @@ function resolveChildStart(
911
950
  if (afterRefs.length > 0) {
912
951
  let maxEnd = laneLeftX;
913
952
  for (const ref of afterRefs) {
953
+ const inlineDate = parseDate(ref);
954
+ if (inlineDate) {
955
+ const xd = ctx.scale.forwardWithinDomain(inlineDate);
956
+ if (xd !== null) maxEnd = Math.max(maxEnd, xd);
957
+ continue;
958
+ }
914
959
  const endX = ctx.entityRightEdges.get(ref);
915
960
  if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
916
961
  }
@@ -1121,7 +1166,14 @@ function computeContentEndDay(
1121
1166
  const milestoneEnd = new Map<string, number>();
1122
1167
  let maxDay = 0;
1123
1168
 
1124
- const refEndDay = (ref: string): number => {
1169
+ // Resolve a single `after:` element to a day-offset from `startDate`.
1170
+ // The element is either an entity id (looked up in itemEnd / anchorEnd /
1171
+ // milestoneEnd) or an inline ISO date literal (converted directly via
1172
+ // `daysBetween`). The validator already enforces "at most one inline date
1173
+ // per direction", so at most one element per list will hit the date path.
1174
+ const resolveAfterDay = (ref: string): number => {
1175
+ const inlineDate = parseDate(ref);
1176
+ if (inlineDate) return daysBetween(startDate, inlineDate);
1125
1177
  if (itemEnd.has(ref)) return itemEnd.get(ref)!;
1126
1178
  if (anchorEnd.has(ref)) return anchorEnd.get(ref)!;
1127
1179
  if (milestoneEnd.has(ref)) return milestoneEnd.get(ref)!;
@@ -1164,7 +1216,7 @@ function computeContentEndDay(
1164
1216
  } else if (startProp) {
1165
1217
  start = daysBetween(startDate, startProp);
1166
1218
  } else if (afterRefs.length > 0) {
1167
- start = Math.max(prevEnd, ...afterRefs.map(refEndDay));
1219
+ start = Math.max(prevEnd, ...afterRefs.map(resolveAfterDay));
1168
1220
  }
1169
1221
  const end = start + dur;
1170
1222
  if (node.name) itemEnd.set(node.name, end);
@@ -1172,17 +1224,30 @@ function computeContentEndDay(
1172
1224
  }
1173
1225
  if (isParallelBlock(node)) {
1174
1226
  // All children share the parallel's start; the block's effective
1175
- // end is the maximum child end.
1176
- let parallelEnd = prevEnd;
1227
+ // end is the maximum child end. The parallel's own `after:`
1228
+ // (including inline-date pins) widens that shared start.
1229
+ const afterRefs = propValues(node.properties, 'after');
1230
+ const containerStart =
1231
+ afterRefs.length > 0
1232
+ ? Math.max(prevEnd, ...afterRefs.map(resolveAfterDay))
1233
+ : prevEnd;
1234
+ let parallelEnd = containerStart;
1177
1235
  for (const child of node.content) {
1178
1236
  if (child.$type === 'DescriptionDirective') continue;
1179
- const childEnd = walkNode(child as ItemDeclaration | GroupBlock, prevEnd);
1237
+ const childEnd = walkNode(child as ItemDeclaration | GroupBlock, containerStart);
1180
1238
  parallelEnd = Math.max(parallelEnd, childEnd);
1181
1239
  }
1182
1240
  return parallelEnd;
1183
1241
  }
1184
1242
  if (isGroupBlock(node)) {
1185
- return walkLane(node.content as SwimlaneDeclaration['content'], prevEnd);
1243
+ // The group's own `after:` (including inline-date pins) widens
1244
+ // the baseline before walking the inner sequential lane.
1245
+ const afterRefs = propValues(node.properties, 'after');
1246
+ const containerStart =
1247
+ afterRefs.length > 0
1248
+ ? Math.max(prevEnd, ...afterRefs.map(resolveAfterDay))
1249
+ : prevEnd;
1250
+ return walkLane(node.content as SwimlaneDeclaration['content'], containerStart);
1186
1251
  }
1187
1252
  return prevEnd;
1188
1253
  };
@@ -1202,7 +1267,12 @@ function computeContentEndDay(
1202
1267
  }
1203
1268
  const after = propValues(ms.properties, 'after');
1204
1269
  if (after.length > 0) {
1205
- const day = Math.max(0, ...after.map(refEndDay));
1270
+ // Milestones disallow inline date literals at the validator
1271
+ // level (rule 24a — milestones already have `date:`). The
1272
+ // shared `resolveAfterDay` helper still handles such input
1273
+ // gracefully if it slips past, treating the date as the
1274
+ // milestone's effective day.
1275
+ const day = Math.max(0, ...after.map(resolveAfterDay));
1206
1276
  if (ms.name) milestoneEnd.set(ms.name, day);
1207
1277
  maxDay = Math.max(maxDay, day);
1208
1278
  }
@@ -14,6 +14,8 @@
14
14
  import type { EntityProperty, GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
15
15
  import { isItemDeclaration } from '@nowline/core';
16
16
  import { deriveItemDurationDays } from '../calendar.js';
17
+ import { propValues } from '../dsl-utils.js';
18
+ import { computeContainerInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
17
19
  import type { LayoutContext, TrackCursor } from '../layout-context.js';
18
20
  import { RowPacker } from '../row-packer.js';
19
21
  import { resolveStyle } from '../style-resolution.js';
@@ -240,6 +242,11 @@ export class GroupNode {
240
242
  ctx.entityRightEdges.set(id, box.x + box.width);
241
243
  }
242
244
  ctx.currentFlowKey = previousFlowKey;
245
+ const inlineDatePins = computeContainerInlineDatePins({
246
+ box,
247
+ afterDate: pickInlineDate(propValues(node.properties, 'after')),
248
+ beforeDate: pickInlineDate(propValues(node.properties, 'before')),
249
+ });
243
250
  return {
244
251
  kind: 'group',
245
252
  id,
@@ -247,6 +254,7 @@ export class GroupNode {
247
254
  box,
248
255
  children,
249
256
  style,
257
+ inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
250
258
  };
251
259
  }
252
260
  }
@@ -6,6 +6,8 @@
6
6
  // helpers in `layout.ts`).
7
7
 
8
8
  import type { GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
9
+ import { propValues } from '../dsl-utils.js';
10
+ import { computeContainerInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
9
11
  import type { LayoutContext, TrackCursor } from '../layout-context.js';
10
12
  import { resolveStyle } from '../style-resolution.js';
11
13
  import { TRACK_BLOCK_TAIL_GUTTER_PX } from '../themes/shared.js';
@@ -89,6 +91,12 @@ export class ParallelNode {
89
91
  ctx.entityRightEdges.set(id, box.x + box.width);
90
92
  }
91
93
 
94
+ const inlineDatePins = computeContainerInlineDatePins({
95
+ box,
96
+ afterDate: pickInlineDate(propValues(node.properties, 'after')),
97
+ beforeDate: pickInlineDate(propValues(node.properties, 'before')),
98
+ });
99
+
92
100
  return {
93
101
  kind: 'parallel',
94
102
  id,
@@ -96,6 +104,7 @@ export class ParallelNode {
96
104
  box,
97
105
  children,
98
106
  style,
107
+ inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
99
108
  };
100
109
  }
101
110
  }
package/src/types.ts CHANGED
@@ -223,6 +223,30 @@ export interface PositionedNowline {
223
223
  style: ResolvedStyle;
224
224
  }
225
225
 
226
+ /**
227
+ * One inline-date pin (`after:DATE` / `before:DATE`) attached to an entity.
228
+ * The renderer paints a small calendar glyph at `glyphTopLeft` with side
229
+ * length `glyphSize`, fills with the entity's resolved meta color, and emits
230
+ * a `<title>` child carrying `isoDate` for native browser tooltips. There is
231
+ * no inline date caption — see specs/rendering.md "Inline-date glyph".
232
+ *
233
+ * `side` decides which corner the glyph belongs in (top-LEFT for `after`,
234
+ * top-RIGHT for `before`). The validator enforces "at most one inline date
235
+ * per direction" so `inlineDatePins` carries 0..2 entries.
236
+ *
237
+ * `spilled === true` means the entity (item bar) was too narrow to host the
238
+ * glyph inside its decoration row, so `glyphTopLeft` points at the spill
239
+ * column outside the bar instead. Group and parallel containers never spill;
240
+ * they always have room for the glyph in their bounding box.
241
+ */
242
+ export interface InlineDatePin {
243
+ side: 'after' | 'before';
244
+ isoDate: string;
245
+ glyphTopLeft: Point;
246
+ glyphSize: number;
247
+ spilled: boolean;
248
+ }
249
+
226
250
  export interface PositionedItem {
227
251
  kind: 'item';
228
252
  id?: string;
@@ -232,6 +256,10 @@ export interface PositionedItem {
232
256
  progressFraction: number; // 0..1; 1 == fully filled
233
257
  footnoteIndicators: number[]; // 1-based superscript numbers, empty when no footnotes
234
258
  labelChips: PositionedLabelChip[];
259
+ /** Inline-date pins attached to this item (`after:DATE` / `before:DATE`).
260
+ * Empty (or undefined) when the item has no inline-date pins. See
261
+ * `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
262
+ inlineDatePins?: InlineDatePin[];
235
263
  /** True when the chip row's natural total width exceeded the bar's
236
264
  * effective inner width and the whole row spilled past the bar's
237
265
  * right edge. The chips' `box.x` already reflects the spilled
@@ -359,6 +387,9 @@ export interface PositionedGroup {
359
387
  box: BoundingBox;
360
388
  // Bracket is drawn on the left edge when style.bracket != 'none'.
361
389
  children: PositionedTrackChild[];
390
+ /** Inline-date pins attached to this group (`after:DATE` / `before:DATE`).
391
+ * See `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
392
+ inlineDatePins?: InlineDatePin[];
362
393
  style: ResolvedStyle;
363
394
  }
364
395
 
@@ -368,6 +399,9 @@ export interface PositionedParallel {
368
399
  title?: string;
369
400
  box: BoundingBox;
370
401
  children: PositionedTrackChild[]; // sub-tracks stacked vertically
402
+ /** Inline-date pins attached to this parallel (`after:DATE` / `before:DATE`).
403
+ * See `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
404
+ inlineDatePins?: InlineDatePin[];
371
405
  style: ResolvedStyle;
372
406
  }
373
407