@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,165 @@
1
+ // Frame-tab chiclet geometry — the rounded label tab that overhangs a
2
+ // swimlane's frame. Both the layout (collision math) and the renderer
3
+ // (drawing) call `frameTabGeometry` so the chiclet's painted footprint
4
+ // matches the bounding box layout reserves for it, and so the same
5
+ // helper computes WHERE inside the chiclet each text element lands.
6
+ //
7
+ // Design note: this helper separates two concerns that used to be
8
+ // conflated in a single set of "column widths":
9
+ //
10
+ // * Text-end positions (`titleX`, `ownerX`, `badgeX`) are computed
11
+ // from estimated actual text widths plus a small explicit
12
+ // `FRAME_TAB_INNER_GAP_PX`. The renderer paints elements at these
13
+ // X coordinates directly — no second placement pass.
14
+ //
15
+ // * Chiclet width (`tabW`) is computed from the right edge of the
16
+ // last painted element plus `FRAME_TAB_RIGHT_INSET_PX`, with a
17
+ // small minimum so a 2–3 char solo title still produces a usable
18
+ // chip. This means short labels naturally shrink-wrap their
19
+ // chiclet rather than reserving a wide whitespace column.
20
+ //
21
+ // The per-char width factors are calibrated to actual avg-char-width
22
+ // of the system sans-serif stack at the relevant font sizes / weights.
23
+ // They lean slightly conservative (~5 % over actual) so the chiclet
24
+ // never visually clips its label even when the runtime font's metrics
25
+ // exceed the calibration target.
26
+
27
+ /**
28
+ * Px-per-char for a 12 pt 600-weight title in `FONT_STACK.sans`.
29
+ * Calibrated against system-ui at 12 pt bold (~6.3 px/char actual);
30
+ * 6.5 leaves a small safety margin without producing a wide gap to
31
+ * the owner / badge that follows.
32
+ */
33
+ export const FRAME_TAB_TITLE_PX_PER_CHAR = 6.5;
34
+
35
+ /**
36
+ * Px-per-char for a 10 pt regular owner suffix in `FONT_STACK.sans`.
37
+ * Calibrated against system-ui at 10 pt regular (~5 px/char actual).
38
+ */
39
+ export const FRAME_TAB_OWNER_PX_PER_CHAR = 5;
40
+
41
+ /**
42
+ * Visible gap (px) between adjacent text elements inside the chiclet:
43
+ * title→owner, owner→badge, and (no-owner) title→badge. Small enough
44
+ * to read as a single chip but big enough that the eye can still
45
+ * separate the tokens.
46
+ */
47
+ export const FRAME_TAB_INNER_GAP_PX = 6;
48
+
49
+ /** Horizontal inset (px) from the chiclet's left edge to the title text. */
50
+ export const FRAME_TAB_LEFT_INSET_PX = 12;
51
+
52
+ /** Horizontal inset (px) from the rightmost element's right edge to the chiclet's right edge. */
53
+ export const FRAME_TAB_RIGHT_INSET_PX = 12;
54
+
55
+ /**
56
+ * Minimum total chiclet width (px). Acts as a floor so very short
57
+ * solo titles ("Q1", "Mob") don't produce a tiny chip that's hard to
58
+ * notice. Owner / badge presence almost always pushes the chiclet
59
+ * past this floor on its own.
60
+ */
61
+ export const FRAME_TAB_MIN_WIDTH_PX = 56;
62
+
63
+ /** Horizontal offset (px) from the swimlane box's left edge to the tab's left edge. */
64
+ export const FRAME_TAB_OFFSET_FROM_BOX_PX = 10;
65
+
66
+ export interface FrameTabGeometry {
67
+ /** Estimated rendered width (px) of the title text, no min-clamp. */
68
+ titleTextWidth: number;
69
+ /** Estimated rendered width (px) of the owner suffix; 0 when no owner. */
70
+ ownerTextWidth: number;
71
+ /** Width (px) of the capacity badge as supplied by the caller; 0 when none. */
72
+ capacityBadgeWidth: number;
73
+ /** Width (px) of the footnote indicator text as supplied by the caller; 0 when none. */
74
+ footnoteIndicatorWidth: number;
75
+
76
+ /** Canvas X (px) where the title text is painted. */
77
+ titleX: number;
78
+ /** Canvas X (px) where the owner text is painted; 0 when no owner. */
79
+ ownerX: number;
80
+ /** Canvas X (px) where the capacity badge starts; 0 when no badge. */
81
+ badgeX: number;
82
+ /**
83
+ * Canvas X (px) for the right edge of the footnote indicator text
84
+ * (use with `text-anchor: end`); 0 when no footnote. Sits inside
85
+ * the chiclet just before the right inset.
86
+ */
87
+ footnoteRightX: number;
88
+
89
+ /** Left X (canvas px) of the chiclet rectangle. */
90
+ tabX: number;
91
+ /** Total chiclet width (px). */
92
+ tabW: number;
93
+ /** Right X (canvas px) of the chiclet — convenience for layout collisions. */
94
+ rightX: number;
95
+ }
96
+
97
+ /**
98
+ * Single source of truth for the swimlane chiclet's geometry.
99
+ *
100
+ * `capacityBadgeWidth` and `footnoteIndicatorWidth` are supplied by
101
+ * the caller — they depend on resolved icon shape / footnote-indicator
102
+ * string which neither the layout nor the renderer wants to duplicate.
103
+ * Pass 0 (or omit) when the lane has no capacity badge / footnote
104
+ * indicators to render.
105
+ *
106
+ * Layout order inside the chiclet, left → right:
107
+ *
108
+ * [LEFT_INSET] title (INNER_GAP) owner (INNER_GAP) badge (INNER_GAP) footnote [RIGHT_INSET]
109
+ *
110
+ * Each element is optional except title; gaps are inserted only between
111
+ * present elements.
112
+ */
113
+ export function frameTabGeometry(
114
+ boxX: number,
115
+ title: string,
116
+ owner: string | undefined,
117
+ capacityBadgeWidth: number = 0,
118
+ footnoteIndicatorWidth: number = 0,
119
+ ): FrameTabGeometry {
120
+ const tabX = boxX + FRAME_TAB_OFFSET_FROM_BOX_PX;
121
+ const titleX = tabX + FRAME_TAB_LEFT_INSET_PX;
122
+
123
+ const titleTextWidth = title.length * FRAME_TAB_TITLE_PX_PER_CHAR;
124
+ let cursorX = titleX + titleTextWidth;
125
+
126
+ let ownerTextWidth = 0;
127
+ let ownerX = 0;
128
+ if (owner) {
129
+ ownerTextWidth = `owner: ${owner}`.length * FRAME_TAB_OWNER_PX_PER_CHAR;
130
+ ownerX = cursorX + FRAME_TAB_INNER_GAP_PX;
131
+ cursorX = ownerX + ownerTextWidth;
132
+ }
133
+
134
+ let badgeX = 0;
135
+ if (capacityBadgeWidth > 0) {
136
+ badgeX = cursorX + FRAME_TAB_INNER_GAP_PX;
137
+ cursorX = badgeX + capacityBadgeWidth;
138
+ }
139
+
140
+ let footnoteRightX = 0;
141
+ if (footnoteIndicatorWidth > 0) {
142
+ // Footnote indicator paints with `text-anchor: end`, so its
143
+ // X is the RIGHT edge of the text. Add the gap before it and
144
+ // its own width to the running content cursor.
145
+ footnoteRightX = cursorX + FRAME_TAB_INNER_GAP_PX + footnoteIndicatorWidth;
146
+ cursorX = footnoteRightX;
147
+ }
148
+
149
+ const contentW = cursorX - tabX + FRAME_TAB_RIGHT_INSET_PX;
150
+ const tabW = Math.max(FRAME_TAB_MIN_WIDTH_PX, contentW);
151
+
152
+ return {
153
+ titleTextWidth,
154
+ ownerTextWidth,
155
+ capacityBadgeWidth,
156
+ footnoteIndicatorWidth,
157
+ titleX,
158
+ ownerX,
159
+ badgeX,
160
+ footnoteRightX,
161
+ tabX,
162
+ tabW,
163
+ rightX: tabX + tabW,
164
+ };
165
+ }
@@ -0,0 +1,48 @@
1
+ // Header card geometry — sizing and text-stack metrics for the
2
+ // "header card" that sits beside the timeline (title + author block).
3
+ //
4
+ // Layout owns the card *size* (computed up-front in `sizeBesideHeader`
5
+ // from wrapped title/author text). The renderer paints the rectangle +
6
+ // text using the *same* paddings, line heights, and font sizes so the
7
+ // card is laid out and drawn consistently.
8
+ //
9
+ // Bumping any of these constants changes the visible size of the
10
+ // header card and the position of every text line inside it. Always
11
+ // keep the layout's `sizeBesideHeader` and the renderer's
12
+ // `renderHeader` aligned by going through this module.
13
+
14
+ /** Horizontal padding (px) inside the card from edge to text. */
15
+ export const HEADER_CARD_PADDING_X = 16;
16
+
17
+ /**
18
+ * Top padding (px) — measured to the BASELINE of the first title
19
+ * line. Larger than a typical "padding top" because it includes the
20
+ * cap-to-baseline distance for the 16 pt title font.
21
+ */
22
+ export const HEADER_CARD_PADDING_TOP = 26;
23
+
24
+ /**
25
+ * Bottom padding (px) below the descender of the last text line.
26
+ * Symmetric-feeling visual padding (smaller than `PADDING_TOP` since
27
+ * top includes the cap-to-baseline distance).
28
+ */
29
+ export const HEADER_CARD_PADDING_BOTTOM = 14;
30
+
31
+ /** Baseline-to-baseline spacing between consecutive title lines. */
32
+ export const HEADER_TITLE_LINE_HEIGHT_PX = 20;
33
+
34
+ /** Baseline-to-baseline spacing between consecutive author lines. */
35
+ export const HEADER_AUTHOR_LINE_HEIGHT_PX = 14;
36
+
37
+ /**
38
+ * Vertical gap (px) between the LAST title baseline and the FIRST
39
+ * author baseline. Larger than a normal line height because it spans
40
+ * the title's descender + the author's cap height.
41
+ */
42
+ export const HEADER_TITLE_TO_AUTHOR_GAP_PX = 18;
43
+
44
+ /** Font size (px) of the title text. */
45
+ export const HEADER_TITLE_FONT_SIZE_PX = 16;
46
+
47
+ /** Font size (px) of the author text (subtitle). */
48
+ export const HEADER_AUTHOR_FONT_SIZE_PX = 11;
package/src/i18n.ts ADDED
@@ -0,0 +1,124 @@
1
+ // Locale-aware strings used by the layout pipeline. Owns three things:
2
+ // - The BCP-47 fallback chain (`fr-CA → fr → en-US`).
3
+ // - A small message table for chrome strings the renderer paints
4
+ // directly (now-pill, quarter prefix).
5
+ // - The locale token passed to `Intl.DateTimeFormat` for axis labels.
6
+ //
7
+ // Today the table is inline; m-loc-d / m-loc-e (per `specs/localization.md`)
8
+ // will move this to a proper `messages.<locale>.ts` bundle layout. The
9
+ // shape here is deliberately minimal so that swap is mechanical.
10
+
11
+ export const DEFAULT_LOCALE = 'en-US';
12
+
13
+ export interface LocaleStrings {
14
+ /** Short label painted inside the now-line pill. Sized for ~3-6 chars. */
15
+ nowLabel: string;
16
+ /** Prefix used when the timeline scale is quarters (e.g. `Q1 2026` / `T1 2026`). */
17
+ quarterPrefix: string;
18
+ }
19
+
20
+ // Bundle entries keyed by language tag. The fallback chain strips the
21
+ // trailing `-SUBTAG` and retries; a missing key in a child bundle falls
22
+ // through to the parent. `en-US` is the root.
23
+ //
24
+ // Region overlays (fr-CA, fr-FR) are intentionally omitted: at launch they
25
+ // have nothing region-specific to override. Empty overlays are a feature,
26
+ // not a bug — they exist as a contract for future divergence. See
27
+ // `specs/localization.md` § "Locale resolution".
28
+ const BUNDLES: Record<string, Partial<LocaleStrings>> = {
29
+ 'en-US': {
30
+ nowLabel: 'now',
31
+ quarterPrefix: 'Q',
32
+ },
33
+ fr: {
34
+ // Short form of "maintenant"; keeps the pill compact while staying
35
+ // correct French. The full word is too wide for the ~3-6 char pill
36
+ // budget without geometry work (tracked in m-loc-c).
37
+ nowLabel: 'maint.',
38
+ // `T` for trimestre — the standard French business convention.
39
+ quarterPrefix: 'T',
40
+ },
41
+ };
42
+
43
+ /**
44
+ * Resolve the effective locale for the rendered artifact.
45
+ *
46
+ * Precedence (highest wins):
47
+ * 1. `directiveLocale` — `nowline v1 locale:fr-CA` from the file itself.
48
+ * The file is the artifact; its declared locale is authoritative the
49
+ * same way `<html lang="fr">` is on a web page or `:lang:` is in
50
+ * AsciiDoc. This guarantees cross-machine determinism: a French
51
+ * roadmap renders French even when invoked from a US-locale shell.
52
+ * 2. `override` — the CLI `--locale` flag plus env-var fallback,
53
+ * already resolved by the CLI before calling layout. Used only when
54
+ * the file declines to declare its own locale.
55
+ * 3. `DEFAULT_LOCALE` (`en-US`).
56
+ *
57
+ * Returns the input string verbatim — callers downstream walk the BCP-47
58
+ * fallback chain to find string entries.
59
+ *
60
+ * Note: the operator's locale (CLI flag / env vars) controls a separate,
61
+ * independent chain for terminal output (validator diagnostics, --help,
62
+ * verbose logs). See `specs/localization.md` for the two-chain model.
63
+ */
64
+ export function resolveLocale(
65
+ override: string | undefined,
66
+ directiveLocale: string | undefined,
67
+ ): string {
68
+ return directiveLocale ?? override ?? DEFAULT_LOCALE;
69
+ }
70
+
71
+ /**
72
+ * Look up a locale's chrome strings, walking the BCP-47 tree until a
73
+ * value is found. The walk strips the trailing `-SUBTAG` and retries:
74
+ * `fr-CA → fr → en-US`
75
+ * `fr-BE → fr → en-US` (no fr-BE bundle yet — works for free)
76
+ * `de-AT → de → en-US` (no de bundles yet — falls all the way through)
77
+ *
78
+ * Always returns a fully-populated `LocaleStrings` because the `en-US`
79
+ * root has every key.
80
+ */
81
+ export function localeStrings(locale: string): LocaleStrings {
82
+ const result: Partial<LocaleStrings> = {};
83
+ for (const tag of fallbackChain(locale)) {
84
+ const bundle = BUNDLES[tag];
85
+ if (!bundle) continue;
86
+ for (const key of Object.keys(bundle) as (keyof LocaleStrings)[]) {
87
+ if (result[key] === undefined && bundle[key] !== undefined) {
88
+ result[key] = bundle[key];
89
+ }
90
+ }
91
+ }
92
+ // The root bundle (`en-US`) is always populated, so this cast is safe.
93
+ return result as LocaleStrings;
94
+ }
95
+
96
+ /**
97
+ * BCP-47 fallback chain for a locale tag. Strips trailing `-SUBTAG`s
98
+ * one at a time until a primary subtag remains, then appends the root
99
+ * locale. Case-normalized to lowercase primary + uppercase region so
100
+ * lookups are stable regardless of input casing.
101
+ */
102
+ export function fallbackChain(locale: string): string[] {
103
+ const chain: string[] = [];
104
+ let current = normalizeTag(locale);
105
+ while (current.length > 0) {
106
+ if (!chain.includes(current)) chain.push(current);
107
+ const dash = current.lastIndexOf('-');
108
+ if (dash <= 0) break;
109
+ current = current.slice(0, dash);
110
+ }
111
+ if (!chain.includes(DEFAULT_LOCALE)) chain.push(DEFAULT_LOCALE);
112
+ return chain;
113
+ }
114
+
115
+ function normalizeTag(locale: string): string {
116
+ const parts = locale.split('-');
117
+ return parts
118
+ .map((part, index) => {
119
+ if (index === 0) return part.toLowerCase();
120
+ // Region subtag: 2 letters → upper-case; 3 digits → keep as-is.
121
+ return /^[a-zA-Z]{2}$/.test(part) ? part.toUpperCase() : part;
122
+ })
123
+ .join('-');
124
+ }
@@ -0,0 +1,156 @@
1
+ // Include-region chrome geometry — the label tab + content badge +
2
+ // source-path bookmark that float across the top edge of an isolated
3
+ // `include {}` region's dashed bracket. Both the layout (which needs
4
+ // to know the chrome's right edge to shrink-wrap the bracket) and the
5
+ // renderer (which paints each element) call `includeChromeGeometry`,
6
+ // so the painted footprint and the layout's reserved footprint stay
7
+ // in sync. Mirrors `frameTabGeometry`'s "compute placement X for each
8
+ // element + derive total width from the cursor" model.
9
+ //
10
+ // Layout order, left → right:
11
+ //
12
+ // [LEFT_INSET] tab(LABEL) (BADGE_GAP) badge (SOURCE_GAP) [HALO_PAD] source [HALO_PAD]
13
+ //
14
+ // The badge and source-path are optional in spirit but always present
15
+ // for current `include` regions; the helper passes `0` widths through
16
+ // cleanly should that ever change.
17
+
18
+ /**
19
+ * Horizontal offset (px) from the include region's bounding-box left
20
+ * edge to the label tab's left edge. Equals the region's outer pad
21
+ * (8 px from `renderIncludeRegion`) plus a 16 px tab inset that keeps
22
+ * the chiclet visually anchored inside the dashed bracket rather than
23
+ * straddling its rounded corner.
24
+ */
25
+ export const INCLUDE_CHROME_OFFSET_FROM_BOX_PX = 24;
26
+
27
+ /** Horizontal inset (px) from the tab's left edge to the label text. */
28
+ export const INCLUDE_CHROME_TAB_LEFT_INSET_PX = 10;
29
+ /**
30
+ * Horizontal inset (px) from the label text's right edge to the tab's
31
+ * right edge. Same as the left inset so the label appears optically
32
+ * centered inside its chiclet.
33
+ */
34
+ export const INCLUDE_CHROME_TAB_RIGHT_INSET_PX = 10;
35
+
36
+ /**
37
+ * Px-per-char for the label rendered at 11 pt 600w in `FONT_STACK.sans`.
38
+ * Calibrated against system-ui at 11 pt bold (~5.8 px/char actual);
39
+ * 6.0 leaves a small safety margin without producing wide right
40
+ * whitespace inside the chiclet.
41
+ */
42
+ export const INCLUDE_CHROME_TAB_LABEL_PER_CHAR_PX = 6;
43
+
44
+ /**
45
+ * Minimum total chiclet width (px). A floor so tiny labels still
46
+ * produce a chip wide enough to read; the badge/source bookmark sit
47
+ * outside the chiclet so they never push it past this minimum.
48
+ */
49
+ export const INCLUDE_CHROME_TAB_MIN_WIDTH_PX = 60;
50
+
51
+ /** Gap (px) between the chiclet's right edge and the content badge. */
52
+ export const INCLUDE_CHROME_BADGE_GAP_PX = 6;
53
+ /** Square content-badge tile size (px). */
54
+ export const INCLUDE_CHROME_BADGE_SIZE_PX = 18;
55
+
56
+ /** Gap (px) between the badge's right edge and the source-path text. */
57
+ export const INCLUDE_CHROME_SOURCE_GAP_PX = 6;
58
+ /**
59
+ * Px-per-char for the source-path rendered at 9 pt in `FONT_STACK.mono`.
60
+ * Calibrated against ui-monospace at 9 pt regular (~5.4 px/char actual);
61
+ * 5.5 leaves a small safety margin so the halo never clips the path.
62
+ */
63
+ export const INCLUDE_CHROME_SOURCE_PER_CHAR_PX = 5.5;
64
+ /**
65
+ * Halo padding (px) on each side of the source-path text. The halo
66
+ * masks the dashed bracket border that runs through the text's
67
+ * baseline so the path stays legible.
68
+ */
69
+ export const INCLUDE_CHROME_SOURCE_HALO_PAD_PX = 3;
70
+
71
+ export interface IncludeChromeGeometry {
72
+ /** Estimated rendered width (px) of the label text, no min-clamp. */
73
+ labelTextWidth: number;
74
+ /** Estimated rendered width (px) of the source-path text; 0 when no source. */
75
+ sourceTextWidth: number;
76
+
77
+ /** Left X (canvas px) of the chiclet rectangle. */
78
+ tabX: number;
79
+ /** Total chiclet width (px), with min-width floor applied. */
80
+ tabWidth: number;
81
+ /** Canvas X (px) where the label text is painted (left-anchored). */
82
+ tabLabelX: number;
83
+
84
+ /** Left X (canvas px) of the content-badge tile; 0 when no badge. */
85
+ badgeX: number;
86
+ /** Square content-badge tile size (px); mirrors `INCLUDE_CHROME_BADGE_SIZE_PX`. */
87
+ badgeSize: number;
88
+
89
+ /** Left X (canvas px) of the source-path halo rect; 0 when no source. */
90
+ sourceHaloX: number;
91
+ /** Width (px) of the source-path halo rect; 0 when no source. */
92
+ sourceHaloWidth: number;
93
+ /** Canvas X (px) where the source-path text is painted (left-anchored); 0 when no source. */
94
+ sourceTextX: number;
95
+
96
+ /**
97
+ * Right edge (canvas px) of the entire chrome strip — used by the
98
+ * layout to size the include region's bounding box so the dashed
99
+ * bracket always encloses its own chrome. Equals the badge's right
100
+ * edge when no source is present, or the halo's right edge when
101
+ * one is.
102
+ */
103
+ chromeRightX: number;
104
+ }
105
+
106
+ /**
107
+ * Single source of truth for the include-region chrome's geometry.
108
+ *
109
+ * `boxX` is the left edge of the include region's bounding box in
110
+ * canvas px. `label` is the chiclet text; `sourcePath` is the
111
+ * breadcrumb beside the badge (pass `''` to omit the bookmark).
112
+ */
113
+ export function includeChromeGeometry(
114
+ boxX: number,
115
+ label: string,
116
+ sourcePath: string,
117
+ ): IncludeChromeGeometry {
118
+ const tabX = boxX + INCLUDE_CHROME_OFFSET_FROM_BOX_PX;
119
+ const tabLabelX = tabX + INCLUDE_CHROME_TAB_LEFT_INSET_PX;
120
+
121
+ const labelTextWidth = label.length * INCLUDE_CHROME_TAB_LABEL_PER_CHAR_PX;
122
+ const tabWidth = Math.max(
123
+ INCLUDE_CHROME_TAB_MIN_WIDTH_PX,
124
+ labelTextWidth + INCLUDE_CHROME_TAB_LEFT_INSET_PX + INCLUDE_CHROME_TAB_RIGHT_INSET_PX,
125
+ );
126
+
127
+ const badgeX = tabX + tabWidth + INCLUDE_CHROME_BADGE_GAP_PX;
128
+ const badgeRightX = badgeX + INCLUDE_CHROME_BADGE_SIZE_PX;
129
+
130
+ let sourceTextWidth = 0;
131
+ let sourceTextX = 0;
132
+ let sourceHaloX = 0;
133
+ let sourceHaloWidth = 0;
134
+ let chromeRightX = badgeRightX;
135
+ if (sourcePath) {
136
+ sourceTextX = badgeRightX + INCLUDE_CHROME_SOURCE_GAP_PX;
137
+ sourceTextWidth = sourcePath.length * INCLUDE_CHROME_SOURCE_PER_CHAR_PX;
138
+ sourceHaloX = sourceTextX - INCLUDE_CHROME_SOURCE_HALO_PAD_PX;
139
+ sourceHaloWidth = sourceTextWidth + INCLUDE_CHROME_SOURCE_HALO_PAD_PX * 2;
140
+ chromeRightX = sourceHaloX + sourceHaloWidth;
141
+ }
142
+
143
+ return {
144
+ labelTextWidth,
145
+ sourceTextWidth,
146
+ tabX,
147
+ tabWidth,
148
+ tabLabelX,
149
+ badgeX,
150
+ badgeSize: INCLUDE_CHROME_BADGE_SIZE_PX,
151
+ sourceHaloX,
152
+ sourceHaloWidth,
153
+ sourceTextX,
154
+ chromeRightX,
155
+ };
156
+ }
package/src/index.ts ADDED
@@ -0,0 +1,116 @@
1
+ export {
2
+ estimateCapacitySuffixWidth,
3
+ formatCapacityNumber,
4
+ parseCapacityValue,
5
+ type ResolvedCapacityIcon,
6
+ resolveCapacityIcon,
7
+ } from './capacity.js';
8
+ export {
9
+ FRAME_TAB_INNER_GAP_PX,
10
+ FRAME_TAB_LEFT_INSET_PX,
11
+ FRAME_TAB_MIN_WIDTH_PX,
12
+ FRAME_TAB_OFFSET_FROM_BOX_PX,
13
+ FRAME_TAB_OWNER_PX_PER_CHAR,
14
+ FRAME_TAB_RIGHT_INSET_PX,
15
+ FRAME_TAB_TITLE_PX_PER_CHAR,
16
+ type FrameTabGeometry,
17
+ frameTabGeometry,
18
+ } from './frame-tab-geometry.js';
19
+ export {
20
+ HEADER_AUTHOR_FONT_SIZE_PX,
21
+ HEADER_AUTHOR_LINE_HEIGHT_PX,
22
+ HEADER_CARD_PADDING_BOTTOM,
23
+ HEADER_CARD_PADDING_TOP,
24
+ HEADER_CARD_PADDING_X,
25
+ HEADER_TITLE_FONT_SIZE_PX,
26
+ HEADER_TITLE_LINE_HEIGHT_PX,
27
+ HEADER_TITLE_TO_AUTHOR_GAP_PX,
28
+ } from './header-card-geometry.js';
29
+ export {
30
+ INCLUDE_CHROME_BADGE_GAP_PX,
31
+ INCLUDE_CHROME_BADGE_SIZE_PX,
32
+ INCLUDE_CHROME_OFFSET_FROM_BOX_PX,
33
+ INCLUDE_CHROME_SOURCE_GAP_PX,
34
+ INCLUDE_CHROME_SOURCE_HALO_PAD_PX,
35
+ INCLUDE_CHROME_SOURCE_PER_CHAR_PX,
36
+ INCLUDE_CHROME_TAB_LABEL_PER_CHAR_PX,
37
+ INCLUDE_CHROME_TAB_LEFT_INSET_PX,
38
+ INCLUDE_CHROME_TAB_MIN_WIDTH_PX,
39
+ INCLUDE_CHROME_TAB_RIGHT_INSET_PX,
40
+ type IncludeChromeGeometry,
41
+ includeChromeGeometry,
42
+ } from './include-chrome-geometry.js';
43
+ export {
44
+ ITEM_CAPTION_INSET_X_PX,
45
+ ITEM_CAPTION_META_BASELINE_OFFSET_PX,
46
+ ITEM_CAPTION_META_FONT_SIZE_PX,
47
+ ITEM_CAPTION_SPILL_GAP_PX,
48
+ ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX,
49
+ ITEM_CAPTION_TITLE_FONT_SIZE_PX,
50
+ ITEM_DECORATION_SPILL_GAP_PX,
51
+ ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX,
52
+ ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
53
+ ITEM_FOOTNOTE_INDICATOR_STEP_PX,
54
+ ITEM_LINK_ICON_INSET_PX,
55
+ ITEM_LINK_ICON_TILE_SIZE_PX,
56
+ ITEM_STATUS_DOT_INSET_RIGHT_PX,
57
+ ITEM_STATUS_DOT_INSET_TOP_PX,
58
+ ITEM_STATUS_DOT_RADIUS_PX,
59
+ LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX,
60
+ LABEL_CHIP_GAP_BETWEEN_PX,
61
+ LABEL_CHIP_HEIGHT_PX,
62
+ MIN_BAR_WIDTH_FOR_DOT_PX,
63
+ MIN_BAR_WIDTH_FOR_FOOTNOTE_PX,
64
+ MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX,
65
+ } from './item-bar-geometry.js';
66
+ export type { LayoutOptions, LayoutResult } from './layout.js';
67
+ export { layoutRoadmap } from './layout.js';
68
+ export {
69
+ darkTheme,
70
+ lightTheme,
71
+ type Theme,
72
+ type ThemeName,
73
+ themes,
74
+ } from './themes/index.js';
75
+ export {
76
+ ACCENT_DASH_PATTERN,
77
+ ATTRIBUTION_BAR_LOGICAL_WIDTH,
78
+ ATTRIBUTION_BAR_LOGICAL_X,
79
+ ATTRIBUTION_INE_LOGICAL_X,
80
+ ATTRIBUTION_LINK,
81
+ ATTRIBUTION_NOW_LOGICAL_X,
82
+ ATTRIBUTION_PREFIX_FONT_SIZE,
83
+ ATTRIBUTION_SCALE,
84
+ ATTRIBUTION_TEXT,
85
+ ATTRIBUTION_WORDMARK_FONT_SIZE,
86
+ CORNER_RADIUS_PX,
87
+ EDGE_CORNER_RADIUS,
88
+ FONT_STACK,
89
+ FOOTNOTE_HEADER_BASELINE_OFFSET_PX,
90
+ FOOTNOTE_HEADER_HEIGHT_PX,
91
+ FOOTNOTE_PANEL_PADDING_PX,
92
+ FOOTNOTE_ROW_HEIGHT,
93
+ FRAME_TAB_HEIGHT_PX,
94
+ FRAME_TAB_LABEL_BASELINE_OFFSET_PX,
95
+ GROUP_BOTTOM_PAD_PX,
96
+ GROUP_BRACKET_LABEL_OVERHANG_PX,
97
+ GROUP_TITLE_TAB_CHAR_WIDTH_PX,
98
+ GROUP_TITLE_TAB_GUTTER_PX,
99
+ GROUP_TITLE_TAB_HEIGHT_PX,
100
+ GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX,
101
+ GROUP_TITLE_TAB_LABEL_FONT_SIZE_PX,
102
+ GROUP_TITLE_TAB_PAD_X_PX,
103
+ NOW_PILL_CORNER_RADIUS_PX,
104
+ NOW_PILL_HEIGHT_PX,
105
+ NOW_PILL_LABEL_BASELINE_OFFSET_PX,
106
+ NOW_PILL_LABEL_FONT_SIZE_PX,
107
+ NOW_PILL_LABEL_INSET_X_PX,
108
+ NOW_PILL_WIDTH_PX,
109
+ NOWLINE_STROKE_WIDTH_PX,
110
+ PROGRESS_STRIP_HEIGHT_PX,
111
+ TEXT_SIZE_PX,
112
+ TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX,
113
+ TIMELINE_TICK_PANEL_HEIGHT_PX,
114
+ TRACK_BLOCK_TAIL_GUTTER_PX,
115
+ } from './themes/shared.js';
116
+ export * from './types.js';