@airmark/quiver 0.33.0 → 0.34.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 (44) hide show
  1. package/README.md +16 -10
  2. package/package.json +7 -7
  3. package/quills/af4141/0.1.0/Quill.yaml +3 -12
  4. package/quills/af4141/0.1.0/example.md +53 -0
  5. package/quills/afmc_moa/0.0.1/Quill.yaml +29 -45
  6. package/quills/afmc_moa/0.0.1/example.md +114 -0
  7. package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +1 -1
  8. package/quills/classic_resume/0.0.1/Quill.yaml +83 -163
  9. package/quills/classic_resume/0.0.1/example.md +111 -0
  10. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +67 -34
  11. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +18 -10
  12. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +36 -7
  13. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +1 -0
  14. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +16 -11
  15. package/quills/classic_resume/0.0.1/plate.typ +54 -54
  16. package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +120 -113
  17. package/quills/cyber_ribbon_chart/0.0.1/example.md +55 -0
  18. package/quills/cyber_ribbon_chart/0.0.1/plate.typ +506 -325
  19. package/quills/daf1206/0.1.0/Quill.yaml +14 -16
  20. package/quills/daf1206/0.1.0/example.md +55 -0
  21. package/quills/daf4392/0.1.0/Quill.yaml +1 -6
  22. package/quills/daf4392/0.1.0/example.md +51 -0
  23. package/quills/usaf_letter/0.1.0/Quill.yaml +7 -42
  24. package/quills/usaf_letter/0.1.0/example.md +35 -0
  25. package/quills/{usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/LICENSE → usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/OFL.txt} +2 -2
  26. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  27. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  28. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +1 -1
  29. package/quills/usaf_letter/0.1.0/plate.typ +1 -4
  30. package/quills/usaf_memo/0.2.0/Quill.yaml +7 -40
  31. package/quills/usaf_memo/0.2.0/example.md +45 -0
  32. package/quills/usaf_memo/0.3.0/Quill.yaml +13 -50
  33. package/quills/usaf_memo/0.3.0/example.md +48 -0
  34. package/quills/{usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/LICENSE → usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt} +2 -2
  35. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
  36. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
  37. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +1 -1
  38. package/quills/usaf_memo/0.3.0/plate.typ +1 -4
  39. package/templates/deros_extension.md +40 -0
  40. package/templates/templates.json +7 -0
  41. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  42. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  43. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  44. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
@@ -1,11 +1,33 @@
1
- #import "@local/quillmark-helper:0.1.0": data
1
+ #import "@local/quillmark-helper:0.1.0": data, roster
2
2
 
3
+ // ─── tokens ──────────────────────────────────────────────────────────────────
4
+ // Grays only, and none lighter than a copier holds: the chart is read from a
5
+ // photocopy, so a distinction it makes is a value contrast or it is lost.
3
6
  #let ink = luma(12%)
4
- #let mute = luma(45%)
5
- #let faint = luma(62%)
6
-
7
- #set page(width: 11in, height: 8.5in, margin: 0.45in)
8
- #set text(font: "Figtree", size: 8pt, fill: ink)
7
+ #let mute = luma(40%)
8
+ #let hair = luma(82%)
9
+ #let shade = luma(88%)
10
+
11
+ // One type scale. A size off it is a fit, set to the width it was given, and
12
+ // says so where it is set.
13
+ #let micro = 6pt
14
+ #let small = 7pt
15
+ #let base = 8pt
16
+ #let lead = 9pt
17
+ #let display = 18pt
18
+
19
+ // Every row under the name hangs its label in one rail: the timeline's rows,
20
+ // the record's and the notes'. The labels are the page's headings, and the
21
+ // rail's edge is the one left edge everything after the name stands on.
22
+ #let rail = 1.2in
23
+ #let name-width = 2.5in
24
+
25
+ #let margin = 0.45in
26
+ #set page(width: 11in, height: 8.5in, margin: margin)
27
+ #set text(size: base, fill: ink)
28
+ // Every gap on the page is set where it is wanted rather than inherited.
29
+ #set block(spacing: 0pt)
30
+ #set par(spacing: 0pt)
9
31
 
10
32
  #set document(
11
33
  title: "Ribbon Chart — " + data.name,
@@ -23,406 +45,565 @@
23
45
  while kids.len() > 0 and pad(kids.last()) { kids = kids.slice(0, -1) }
24
46
  kids.sum(default: [])
25
47
  }
26
- #let blank(v) = { let t = trim(v); t == [] or t == "" }
48
+ #let blank(v) = { let t = trim(v); t == none or t == [] or t == "" }
49
+ #let or-none(v) = if blank(v) { none } else { trim(v) }
50
+ #let plain(c) = if c == none { "" } else if type(c) == str { c } else if c.has("text") {
51
+ c.text
52
+ } else if c.has("children") {
53
+ c.children.map(plain).sum(default: "")
54
+ } else if c.func() == smartquote { "’" } else { " " }
55
+ #let dash = text(fill: mute)[—]
27
56
 
28
57
  // The bundled fallback face carries no geometric glyphs — no ✓, ■, □, ●, ▸ — so
29
- // a mark is drawn rather than set. Held against open is a value contrast, which
30
- // is what survives the photocopier these are read from.
58
+ // a mark is drawn rather than set. Held against open is a value contrast, and
59
+ // the open box is stroked dark enough that a rater still finds it on a copy.
31
60
  #let held-mark = box(baseline: 0.5pt, rect(width: 4.6pt, height: 4.6pt, fill: ink, stroke: 0.5pt + ink))
32
- #let open-mark = box(baseline: 0.5pt, rect(width: 4.6pt, height: 4.6pt, fill: none, stroke: 0.5pt + faint))
33
-
34
- #let section-rule(title) = block(spacing: 0pt, below: 5pt)[
35
- #text(size: 6.6pt, weight: 700, tracking: 0.7pt)[#upper(title)]
36
- #v(2pt)
37
- #line(length: 100%, stroke: 0.6pt + ink)
38
- ]
61
+ #let open-mark = box(baseline: 0.5pt, rect(width: 4.6pt, height: 4.6pt, fill: none, stroke: 0.5pt + mute))
39
62
 
40
- #let fact(label, value) = block(spacing: 0pt)[
41
- #text(size: 5.8pt, weight: 600, tracking: 0.4pt, fill: mute)[#upper(label)]
42
- #v(1.5pt)
43
- #text(size: 8.5pt, weight: 500)[#if value == none { text(fill: faint)[—] } else { value }]
44
- ]
63
+ #let eyebrow(body) = text(size: micro, weight: 700, tracking: 0.5pt, fill: mute, upper(body))
64
+ #let rail-label(body) = text(size: small, weight: 600, fill: mute, body)
65
+ #let divider(above) = block(above: above, below: 10pt, line(length: 100%, stroke: 0.5pt + ink))
45
66
 
46
67
  #let show-date(d) = if d == none { none } else { d.display("[day padding:none] [month repr:short] [year]") }
47
68
 
48
- // ─── the ladder ──────────────────────────────────────────────────────────────
49
- // Every 17X board and school gate, as an offset from the year group, counted
50
- // from the adjusted year group where one is set because that is the year a
51
- // board counts from. The tier is how heavily it prints: a board is a date you
52
- // are ranked against, a look is one of a numbered series, a course is neither.
53
- #let ladder = (
54
- (3, "Cyber 200", "course"),
55
- (4, "SOS window", "course"),
56
- (8, "Maj board", "board"),
57
- (8, "IDE 1st look", "look"),
58
- (9, "Cyber 300", "course"),
59
- (9, "IDE 2nd look", "look"),
60
- (10, "IDE 3rd look", "look"),
61
- (11, "IDE final look", "look"),
62
- (12, "Lt Col board", "board"),
63
- (14, "SDE 1st look", "look"),
64
- (15, "SDE 2nd look", "look"),
65
- (16, "SDE 3rd look", "look"),
66
- (17, "SDE final look", "look"),
67
- )
68
-
69
69
  // ─── inputs ──────────────────────────────────────────────────────────────────
70
+ #let quals = data.qualifications
70
71
  #let comm-yg = data.commissioning_yg
71
- #let adj-yg = data.adjusted_yg
72
- #let board-yg = if adj-yg != 0 { adj-yg } else { comm-yg }
73
- #let start-year = data.timeline_start_year
72
+ // 0 is read as blank: it was this field's blank before the field took `?`.
73
+ #let adj-yg = { let v = data.at("adjusted_yg", default: none); if v == 0 { none } else { v } }
74
+ #let adjusted = adj-yg != none and adj-yg != comm-yg
75
+ #let board-yg = if adjusted { adj-yg } else { comm-yg }
76
+ // A required year not yet filled in arrives as 0. With no start year the window
77
+ // opens on the year group; with neither, as on a new chart, the axis counts
78
+ // from YG+0 and prints no calendar years rather than the years 0 to 11.
79
+ #let start-year = if data.timeline_start_year == 0 { board-yg } else { data.timeline_start_year }
80
+ #let dated = start-year != 0
74
81
  #let years = calc.max(1, data.timeline_years)
75
82
  #let cols = years * 2
83
+ #let last-year = start-year + years - 1
84
+ #let window-end = if dated [#last-year] else [YG+#(last-year - board-yg)]
85
+ #let in-window(y) = y >= start-year and y <= last-year
86
+ #let band-height = 0.46in
87
+ // Marks fit their year column: past twelve years they step off the scale.
76
88
  #let narrow = years > 12
77
- #let band-height = 0.52in
78
- #let chip-size = if narrow { 5.6pt } else { 6.2pt }
79
- #let chip-inset = if narrow { 2pt } else { 3.5pt }
80
-
81
- // Three weights of gate, by what each one is: a board ranks you against a year
82
- // group on a date, a look is one of a numbered series, a course is a seat.
83
- #let chip(label, tier) = {
84
- if tier == "board" {
85
- box(fill: ink, inset: (x: chip-inset, y: 2pt))[#text(size: chip-size, weight: 700, fill: white)[#label]]
86
- } else if tier == "look" {
87
- box(fill: none, stroke: 0.5pt + ink, inset: (x: chip-inset, y: 2pt))[#text(size: chip-size, weight: 600)[#label]]
89
+ #let mark-size = if narrow { 5.6pt } else { micro }
90
+ #let mark-inset = if narrow { 2pt } else { 3.5pt }
91
+
92
+ // A half-year column is H1 (Jan–Jun, even) or H2 (Jul–Dec, odd), and each one is
93
+ // a VML cycle: Winter moves land in the first half, Summer moves in the second.
94
+ #let cycle-of(col) = if calc.rem(col, 2) == 0 { "Winter" } else { "Summer" }
95
+ #let col-of(year, cycle) = (year - start-year) * 2 + if cycle == "Winter" { 0 } else { 1 }
96
+
97
+ #let afsc = or-none(data.afsc)
98
+ #let duty = or-none(data.duty_title)
99
+ #let unit = or-none(data.unit)
100
+ #let move-year = data.at("move_year", default: none)
101
+
102
+ // Where the vectors branch. Everything left of it is the assignment you hold
103
+ // now, which is the same on every row, so it is drawn once across all of them.
104
+ // A move before the window is kept where it falls, so the tours after it are
105
+ // laid from their real start and cut at the left edge rather than shifted.
106
+ #let move-col = if move-year == none { 0 } else { col-of(move-year, data.move_cycle) }
107
+ #let current-span = if data.vectors.len() == 0 { 0 } else { calc.clamp(move-col, 0, cols) }
108
+
109
+ // ─── the ladder ──────────────────────────────────────────────────────────────
110
+ // Every 17X board and school gate, as an offset from the year group, counted
111
+ // from the adjusted year group where one is set because that is the year a
112
+ // board counts from. A board or a course falls in one year; IDE and SDE are
113
+ // each a window of four yearly looks, drawn as one span. A gate is done once
114
+ // any qualification it names is ticked (an IDE candidate has no looks left)
115
+ // and prints struck rather than as something still ahead.
116
+ #let held(keys) = keys.any(k => k in quals.education)
117
+ #let gates = (
118
+ (yg: 3, name: "Cyber 200", board: false, by: ("cyber_200",)),
119
+ (yg: 4, name: "SOS window", board: false, by: ("sos",)),
120
+ (yg: 8, name: "Maj board", board: true, by: ()),
121
+ (yg: 9, name: "Cyber 300", board: false, by: ("cyber_300",)),
122
+ (yg: 12, name: "Lt Col board", board: true, by: ()),
123
+ ).map(g => (..g, year: board-yg + g.yg, done: held(g.by)))
124
+ // A course whose year has passed unticked is what a rater most needs to see, so
125
+ // it stands before the window, in the rail, saying when it was due. A passed
126
+ // board is history.
127
+ #let gates = gates.map(g => (..g, overdue: not g.board and not g.done and g.year < start-year))
128
+ #let looks = ("1st", "2nd", "3rd", "final")
129
+ #let windows = (
130
+ (yg: 8, name: "IDE", by: ("ide_candidate", "ide_graduate")),
131
+ (yg: 14, name: "SDE", by: ("sde_candidate", "sde_graduate")),
132
+ ).map(w => (..w, done: held(w.by), years: looks.enumerate().map(((k, _)) => board-yg + w.yg + k)))
133
+ #let ahead = (
134
+ gates.filter(g => not g.done and g.year > last-year).len()
135
+ + windows.filter(w => not w.done).map(w => w.years.filter(y => y > last-year).len()).sum(default: 0)
136
+ )
137
+
138
+ // A board prints heaviest, since it is a date you are ranked against; a course
139
+ // is a seat, and prints plain. A board holds to one line: at eighteen years
140
+ // "Lt Col board" runs a little into YG+13, which the ladder leaves empty.
141
+ #let gate-mark(g) = {
142
+ let plain-mark(body, ..style) = box(inset: (x: 0.5pt, y: 2pt), text(size: mark-size, ..style, body))
143
+ if g.overdue {
144
+ plain-mark([#g.name, due #g.year], weight: 600, style: "italic")
145
+ } else if g.done {
146
+ plain-mark(strike(stroke: 0.5pt + mute, g.name), fill: mute)
147
+ } else if g.board {
148
+ box(fill: ink, inset: (x: mark-inset, y: 2pt), text(size: mark-size, weight: 700, fill: white, g.name.replace(" ", "\u{a0}")))
88
149
  } else {
89
- box(inset: (x: 0.5pt, y: 2pt))[#text(size: chip-size, fill: mute)[#label]]
150
+ plain-mark(g.name, fill: mute)
90
151
  }
91
152
  }
92
153
 
93
- // One tint per vector, darkest first, so the shade says which row this is.
94
- // Adjacent tours part by a gap and an edge instead, and at the third rank the
95
- // edge is doing all the work — which is what a photocopy leaves anyway.
96
- #let rank-tints = (luma(86%), luma(92%), luma(96%))
97
-
154
+ // A window is one span over its looks in the chart, split by year and named
155
+ // once. Looks already past are history and the span opens at the left edge;
156
+ // looks past the window run it open into the right.
157
+ #let window-mark(w, shown) = {
158
+ let paint = if w.done { mute } else { ink }
159
+ let edge = 0.5pt + paint
160
+ box(
161
+ width: 100%,
162
+ stroke: (
163
+ top: edge,
164
+ bottom: edge,
165
+ left: if w.years.first() < start-year { none } else { edge },
166
+ right: if w.years.last() > last-year { none } else { edge },
167
+ ),
168
+ grid(
169
+ columns: (1fr,) * shown.len(),
170
+ inset: (x: mark-inset, y: 2pt),
171
+ stroke: (x, _) => (left: if x > 0 { 0.4pt + hair }),
172
+ ..shown.enumerate().map(((n, k)) => {
173
+ let name = [#if n == 0 [#w.name~]#looks.at(k)]
174
+ text(size: mark-size, weight: 600, fill: paint, if w.done { strike(stroke: 0.5pt + mute, name) } else { name })
175
+ }),
176
+ ),
177
+ )
178
+ }
98
179
 
99
180
  // ─── vectors ─────────────────────────────────────────────────────────────────
100
- #let vectors = data.vectors.enumerate().map(((i, v)) => (
101
- label: if blank(v.label) { [Vector #(i + 1)] } else { trim(v.label) },
102
- focus: trim(v.focus),
103
- tours: v.tours.map(t => (
104
- title: trim(t.title),
105
- duration: float(t.duration),
106
- vml: t.vml,
107
- school: t.school,
108
- )),
109
- ))
110
-
111
- // A half-year column is H1 (Jan–Jun, even) or H2 (Jul–Dec, odd), and a VML
112
- // cycle picks the phase a tour may start on. Where that means waiting, the wait
113
- // is added to the tour before it rather than left as a hole: you hold a job
114
- // until you move, and an empty half-year on a career chart reads as
115
- // unemployment. Only the first tour can be preceded by nothing, and its indent
116
- // is the half-year before a summer move.
181
+ #let ranks = ("Primary", "Alternate", "Backup")
182
+ #let current-stroke = 0.9pt + ink
183
+ #let constraint-stroke = 0.6pt + ink
184
+ #let school-stroke = (paint: mute, thickness: 0.5pt, dash: (2pt, 1.5pt))
185
+ #let tour-stroke = 0.5pt + luma(55%)
186
+
187
+ // Tours lay end to end from the move, so each one's cycle is where the one
188
+ // before it ended rather than something to keep in step by hand.
117
189
  //
118
- // Nothing is dropped. A tour the window cuts keeps a torn edge and says which
119
- // year it runs past; one starting beyond the window is counted on its row.
120
- #let place(tours) = {
121
- let cursor = 0
190
+ // Nothing is dropped. A tour the window cuts runs into the window's edge open
191
+ // and says which year it runs past; one starting beyond the window is counted
192
+ // on its row.
193
+ #let lay(tours) = {
194
+ let cursor = move-col
122
195
  let out = ()
123
196
  for t in tours {
124
- let phase = if t.vml == "Winter" { 0 } else { 1 }
125
- let start = cursor
126
- if calc.rem(start, 2) != phase { start += 1 }
127
- if start > cursor and out.len() > 0 {
128
- let prev = out.last()
129
- prev.span += start - cursor
130
- prev.held = true
131
- out.at(out.len() - 1) = prev
132
- }
133
- out.push((
134
- tour: t,
135
- start: start,
136
- span: calc.max(1, int(calc.round(t.duration * 2))),
137
- held: false,
138
- ))
139
- cursor = out.last().start + out.last().span
197
+ out.push((tour: t, start: cursor, span: t.span))
198
+ cursor += t.span
140
199
  }
141
- out.map(p => if p.start >= cols { (..p, beyond: true) } else {
142
- (..p, beyond: false, drawn: calc.min(p.span, cols - p.start), clipped: p.start + p.span > cols)
200
+ // A tour over before the window opens is history, not plan.
201
+ out.filter(p => p.start + p.span > 0).map(p => {
202
+ let from = calc.max(0, p.start)
203
+ if from >= cols { (..p, beyond: true) } else {
204
+ (
205
+ ..p,
206
+ beyond: false,
207
+ from: from,
208
+ drawn: calc.min(p.start + p.span, cols) - from,
209
+ cut: p.start < 0,
210
+ clipped: p.start + p.span > cols,
211
+ )
212
+ }
143
213
  })
144
214
  }
145
215
 
146
- // The band is ruled to an even height, so a narrow block cannot grow to fit its
147
- // title: the title sets to the block instead. That is the ceiling `title`
148
- // warns about in Quill.yaml — about twenty characters per year of length.
149
- #let title-size(span) = if span <= 1 { 6.4pt } else if span == 2 { 7.2pt } else { 8pt }
216
+ #let vectors = data.vectors.enumerate().map(((i, v)) => (
217
+ rank: ranks.at(i, default: "Vector " + str(i + 1)),
218
+ track: or-none(v.track),
219
+ // A length outside 0.5 to 4 draws at the nearer end under a dagger the
220
+ // legend explains, since the schema cannot bound a number.
221
+ placed: lay(v.tours.map(t => (
222
+ title: trim(t.title),
223
+ span: calc.clamp(int(calc.round(float(t.years) * 2)), 1, 8),
224
+ out-of-range: t.years < 0.25 or t.years > 4,
225
+ school: t.school,
226
+ ))),
227
+ ))
228
+
229
+ // The band is ruled to an even height, so a block cannot grow to fit its text:
230
+ // the text sheds what the block cannot hold instead. The notes go a phrase at a
231
+ // time, then the title steps down the scale, and each setting is tried flat and
232
+ // then turned up the side before the next. A block too small for any setting is
233
+ // left blank rather than overprinted; that is the ceiling `title` warns about in
234
+ // Quill.yaml, about twelve characters per year of length.
235
+ //
236
+ // The title is broken here rather than by the paragraph, between words only and
237
+ // each line as full as it will go, so "JFHQ-C" never strands its "C" and a word
238
+ // too long for the block rules the setting out rather than running past its
239
+ // edge. A note holds to one line, and turned up the side nothing breaks.
240
+ #let block-text(title, notes, ..style) = layout(size => {
241
+ let (w, h) = (size.width, size.height)
242
+ let words = plain(title).split(" ").filter(x => x != "")
243
+ let face(s, body) = text(size: s, weight: 600, ..style, body)
244
+ // The title in lines no wider than `room`, or in one line given none.
245
+ let lines(s, room) = words.fold((), (out, x) => {
246
+ if out.len() > 0 and (room == none or measure(face(s, out.last() + " " + x)).width <= room) {
247
+ out.slice(0, -1) + (out.last() + " " + x,)
248
+ } else { out + (x,) }
249
+ })
250
+ let typeset((s, n), room) = (
251
+ ..if words.len() > 0 { (face(s, lines(s, room).join(linebreak())),) },
252
+ ..if n != none { (text(size: micro, fill: mute, n),) },
253
+ ).join(linebreak(), default: [])
254
+ let within(c, room, w, h) = { let m = measure(typeset(c, room)); m.width <= w and m.height <= h }
255
+ let settings = notes.map(n => (base, n)) + (base, small, micro).map(s => (s, none))
256
+ block(width: w, height: h, settings.map(c => (
257
+ if within(c, w, w, h) { align(left + horizon, typeset(c, w)) },
258
+ if within(c, none, h, w) { align(center + bottom, rotate(-90deg, reflow: true, block(width: h, typeset(c, none)))) },
259
+ )).flatten().find(s => s != none))
260
+ })
150
261
 
151
- // Half-columns back to years, so a stretched block prints the length it draws
152
- // rather than the length it was given.
153
262
  #let span-label(span) = {
154
263
  let years = span / 2
155
264
  if calc.rem(span, 2) == 0 { [#int(years) yr] } else { [#years yr] }
156
265
  }
157
266
 
267
+ #let constraints = data.constraints.map(c => (
268
+ note: trim(c.note),
269
+ through: c.through,
270
+ end: calc.min(cols, (c.through - start-year + 1) * 2),
271
+ )).filter(c => c.end > 0)
272
+
158
273
  // ═══ IDENTITY ════════════════════════════════════════════════════════════════
274
+ // The name stands on the facts' labels, and the line under it on their values:
275
+ // both hang from the top of their row by one edge, so the two sizes share a
276
+ // baseline, and a degree that wraps or a year group's note hangs below the row
277
+ // rather than lifting it. The AFSC, duty title and unit read as one line,
278
+ // breaking before the unit rather than inside it. The year group printed is the
279
+ // one the boards count from; an adjustment says where it came from, and an
280
+ // unadjusted one prints nothing more.
281
+ #let hang = 0.72 * lead
282
+ #let identity = layout(size => {
283
+ set text(top-edge: hang)
284
+ let parts = (
285
+ if afsc != none { text(weight: 600, afsc) },
286
+ if duty != none { text(weight: 600, duty) },
287
+ if unit != none { text(weight: 500, fill: mute, unit) },
288
+ ).filter(x => x != none)
289
+ let sep = text(fill: mute)[ · ]
290
+ if parts.len() < 2 or measure(parts.join(sep)).width <= size.width {
291
+ parts.join(sep)
292
+ } else {
293
+ parts.slice(0, -1).join(sep) + linebreak() + parts.last()
294
+ }
295
+ })
296
+ #let facts = (
297
+ ("Year group", if board-yg != 0 [#board-yg], if adjusted [adjusted from #comm-yg]),
298
+ ("Date of rank", show-date(data.date_of_rank), none),
299
+ ("Arrived station", show-date(data.date_arrived_station), none),
300
+ ("Advanced degree", or-none(data.advanced_degree), none),
301
+ )
159
302
  #grid(
160
- columns: (2.7in, 1fr),
161
- column-gutter: 18pt,
162
- align: bottom,
163
- [
164
- #text(size: 17pt, weight: 800, tracking: -0.25pt)[#data.name]
165
- #if not blank(data.duty_title) [
166
- #v(2pt)
167
- #text(size: 8pt, weight: 500, fill: mute)[#data.duty_title]
168
- ]
169
- ],
170
- grid(
171
- columns: (1fr,) * 5,
172
- column-gutter: 12pt,
173
- align: top,
174
- fact("Commissioning YG", [#comm-yg]),
175
- fact("Adjusted YG", if adj-yg != 0 { [#adj-yg] } else { none }),
176
- fact("Date of rank", show-date(data.date_of_rank)),
177
- fact("Arrived station", show-date(data.date_arrived_station)),
178
- fact("Advanced degree", if blank(data.advanced_degree) { none } else { data.advanced_degree }),
179
- ),
303
+ columns: (name-width,) + (1fr,) * facts.len(),
304
+ column-gutter: 16pt,
305
+ row-gutter: 3pt,
306
+ align: (_, y) => if y == 0 { bottom } else { top },
307
+ // Raised clear of the line under it by the depth of its descenders.
308
+ pad(bottom: 3pt, text(size: display, weight: 800, tracking: -0.3pt, data.name)),
309
+ ..facts.map(((name, ..)) => eyebrow(name)),
310
+ identity,
311
+ ..facts.map(((_, value, note)) => stack(
312
+ spacing: 3pt,
313
+ text(size: lead, weight: 500, top-edge: hang, if value == none { dash } else { value }),
314
+ ..if note != none { (text(size: micro, fill: mute, note),) },
315
+ )),
180
316
  )
181
317
 
182
- #v(6pt)
183
- #line(length: 100%, stroke: 1.3pt + ink)
184
- #v(9pt)
185
-
186
- // ═══ TIMELINE ════════════════════════════════════════════════════════════════
187
- #let in-window(cy) = cy >= start-year and cy < start-year + years
188
- #let ahead = ladder.filter(m => board-yg + m.at(0) >= start-year + years)
189
-
190
- #block(spacing: 0pt)[
191
- #grid(columns: (1fr, auto), align: (left + bottom, right + bottom),
192
- text(size: 6.6pt, weight: 700, tracking: 0.7pt)[#upper("Assignment vectors & development timeline")],
193
- text(size: 6.2pt, fill: mute)[
194
- CY #start-year–#(start-year + years - 1) · counted from YG #board-yg
195
- #if ahead.len() > 0 [ · #ahead.len() later milestone#if ahead.len() > 1 [s] beyond #(start-year + years - 1)]
196
- ],
197
- )
198
- #v(2pt)
199
- #line(length: 100%, stroke: 0.6pt + ink)
200
- ]
201
- #v(5pt)
318
+ // ═══ PLAN ════════════════════════════════════════════════════════════════════
319
+ #let row-label(body) = table.cell(align: left + horizon, rail-label(body))
320
+ // An empty half-year is its own cell, so the year rules run through it.
321
+ #let empty(n, stroke: none) = range(n).map(_ => table.cell(stroke: stroke)[])
202
322
 
203
323
  #let rows = ()
204
324
 
205
- // year header
206
- #rows.push(table.cell(stroke: none)[])
325
+ // the axis
326
+ #rows.push(table.cell[])
207
327
  #for i in range(years) {
208
- rows.push(table.cell(colspan: 2, stroke: (bottom: 0.7pt + ink), inset: (bottom: 2.5pt))[
209
- #text(size: 8pt, weight: 700)[#(start-year + i)]
210
- #linebreak()
211
- #text(size: 5.8pt, fill: mute)[YG+#(start-year + i - board-yg)]
328
+ let offset = [YG+#(start-year + i - board-yg)]
329
+ rows.push(table.cell(colspan: 2, stroke: (bottom: 0.7pt + ink), inset: (x: 3pt, bottom: 3pt))[
330
+ #if dated [
331
+ #text(weight: 700)[#(start-year + i)]
332
+ #linebreak()
333
+ #text(size: micro, fill: mute, offset)
334
+ ] else { text(weight: 700, offset) }
212
335
  ])
213
336
  }
214
337
 
215
- // milestone row
216
- #rows.push(table.cell(align: left + horizon, stroke: none)[
217
- #text(size: 6.4pt, weight: 600, fill: mute)[Eligibility]
218
- ])
219
- #for i in range(years) {
220
- let cy = start-year + i
221
- let hits = ladder.filter(m => board-yg + m.at(0) == cy)
222
- rows.push(table.cell(colspan: 2, stroke: none, inset: (x: 1.5pt, y: 3pt))[
223
- #for m in hits [
224
- #block(spacing: 2pt, breakable: false)[#chip(m.at(1), m.at(2))]
225
- ]
338
+ // One row per constraint, under the axis: a bar from today to the end of the
339
+ // last year it holds, ruled under and closed at its end, so it reads as "until
340
+ // here". The band is not ruled by year, so a label too long for its bar runs
341
+ // on past its end, on one line, over nothing.
342
+ #for (i, c) in constraints.enumerate() {
343
+ rows.push(if i == 0 { row-label[Constraints] } else { table.cell[] })
344
+ let note = [#text(size: micro, weight: 600)[#c.note]#text(size: micro, fill: mute)[#(" · through " + str(c.through))]]
345
+ // The first stands clear of the axis, which would otherwise close its top.
346
+ rows.push(table.cell(colspan: c.end, inset: (x: 1pt, top: if i == 0 { 3pt } else { 0.5pt }, bottom: 0.5pt))[
347
+ #layout(size => {
348
+ // A bar that runs past the window is left open at its end, since the
349
+ // window's edge is not where it stops.
350
+ let open = c.through > last-year
351
+ let fits = measure(note).width + 8pt <= size.width
352
+ box(width: 100%, height: 8.5pt, stroke: (bottom: constraint-stroke, right: if open { none } else { constraint-stroke }))
353
+ place(left + bottom, dx: if fits { 3pt } else { size.width + 3pt }, dy: -2pt, box(width: 10in, note))
354
+ })
226
355
  ])
356
+ rows += empty(cols - c.end)
357
+ }
358
+
359
+ // Eligibility, in lanes the year rules start from: the boards and courses, each
360
+ // on its year, and under them the IDE and SDE windows, when one is in view.
361
+ #let spans = windows.map(w => (w, range(looks.len()).filter(k => in-window(w.years.at(k))))).filter(((_, shown)) => shown.len() > 0)
362
+ #let lanes = if spans.len() > 0 { 2 } else { 1 }
363
+ #rows.push(table.cell(rowspan: lanes, align: left + horizon, stack(
364
+ spacing: 3pt,
365
+ rail-label[Eligibility],
366
+ ..gates.filter(g => g.overdue).map(gate-mark),
367
+ )))
368
+ #for i in range(years) {
369
+ let here = gates.filter(g => g.year == start-year + i)
370
+ rows.push(table.cell(colspan: 2, inset: (x: 1.5pt, y: 3pt), stack(spacing: 2pt, ..here.map(gate-mark))))
227
371
  }
372
+ #if spans.len() > 0 {
373
+ let at = 0
374
+ for (w, shown) in spans {
375
+ let from = (w.years.at(shown.first()) - start-year) * 2
376
+ rows += empty(from - at)
377
+ rows.push(table.cell(colspan: 2 * shown.len(), inset: (x: 1.5pt, top: 0pt, bottom: 3pt), window-mark(w, shown)))
378
+ at = from + 2 * shown.len()
379
+ }
380
+ rows += empty(cols - at)
381
+ }
382
+
383
+ // The assignment held now, drawn once across every vector row. It reads like a
384
+ // tour block, title over a line of small print, and sheds what its space cannot
385
+ // hold the way one does; with nothing left, the header still names the job. The
386
+ // unit is left to the header throughout.
387
+ #let current-block = block(
388
+ width: 100%,
389
+ height: 100%,
390
+ stroke: current-stroke,
391
+ inset: (x: 3pt, y: 2pt),
392
+ block-text(
393
+ if duty != none { duty } else { [Current assignment] },
394
+ ([Current · to #data.move_cycle #move-year], [to #data.move_cycle #move-year]),
395
+ ),
396
+ )
228
397
 
229
- // one row per vector
398
+ // One row per vector. Its label says which it is, so every duty tour takes the
399
+ // one shade, and a school goes unshaded: a year in a classroom does not read as
400
+ // a year in a job.
230
401
  #for (rank, v) in vectors.enumerate() {
231
- let placed = place(v.tours)
232
- let beyond = placed.filter(p => p.beyond)
233
- let top-rule = (top: 0.4pt + luma(80%))
234
- let tint = rank-tints.at(calc.min(rank, rank-tints.len() - 1))
402
+ let beyond = v.placed.filter(p => p.beyond)
403
+ let top-rule = (top: 0.4pt + hair)
404
+ let sub = ()
405
+ if v.track != none { sub.push(v.rank) }
406
+ if beyond.len() > 0 { sub.push([+#beyond.len() past #window-end]) }
235
407
 
236
408
  rows.push(table.cell(align: left + horizon, stroke: top-rule, inset: (right: 5pt, y: 3pt))[
237
- #text(size: 8.5pt, weight: 700)[#v.label]
238
- #if not blank(v.focus) or beyond.len() > 0 [
409
+ #text(size: lead, weight: 700)[#if v.track != none { v.track } else { v.rank }]
410
+ #if sub.len() > 0 [
239
411
  #linebreak()
240
- #text(size: 6pt, fill: mute)[
241
- #if not blank(v.focus) [#v.focus]
242
- #if beyond.len() > 0 [
243
- #if not blank(v.focus) [ · ]
244
- +#beyond.len() past #(start-year + years - 1)
245
- ]
246
- ]
412
+ #text(size: micro, fill: mute)[#sub.join[ · ]]
247
413
  ]
248
414
  ])
249
415
 
250
- let at = 0
251
- let idx = 0
252
- for p in placed.filter(p => not p.beyond) {
253
- if p.start > at {
254
- rows.push(table.cell(colspan: p.start - at, stroke: top-rule)[])
255
- at = p.start
256
- }
416
+ if rank == 0 and current-span > 0 {
417
+ rows.push(table.cell(colspan: current-span, rowspan: vectors.len(), inset: (x: 1pt, y: 2pt))[#current-block])
418
+ }
419
+
420
+ let at = current-span
421
+ for p in v.placed.filter(p => not p.beyond) {
422
+ let edge = if p.tour.school { school-stroke } else { tour-stroke }
423
+ let notes = (
424
+ [#span-label(p.span)#if p.tour.out-of-range [#sym.dagger]],
425
+ cycle-of(p.start),
426
+ ..if p.clipped { ([runs past #window-end],) },
427
+ )
257
428
  rows.push(table.cell(
258
429
  colspan: p.drawn,
259
430
  stroke: top-rule,
260
- inset: (x: 1pt, y: 2pt),
261
- align: left + horizon,
431
+ inset: (left: if p.cut { 0pt } else { 1pt }, right: if p.clipped { 0pt } else { 1pt }, y: 2pt),
262
432
  )[
263
433
  // The block is drawn inside its cell rather than as the cell's fill, so
264
- // adjacent tours are parted by a gap and an edge instead of by a second
265
- // shade — which is what lets the shade say which vector this is.
266
- #box(
434
+ // adjacent tours part by a gap and an edge. A tour the window cuts runs
435
+ // to the window's edge with that end open.
436
+ #block(
267
437
  width: 100%,
268
438
  height: 100%,
269
- fill: if p.tour.school { none } else { tint },
270
- stroke: if p.tour.school { (paint: mute, thickness: 0.5pt, dash: (2pt, 1.5pt)) } else { 0.5pt + luma(55%) },
439
+ fill: if p.tour.school { none } else { shade },
440
+ stroke: (
441
+ top: edge,
442
+ bottom: edge,
443
+ left: if p.cut { none } else { edge },
444
+ right: if p.clipped { none } else { edge },
445
+ ),
271
446
  inset: (x: 3pt, y: 2pt),
272
- baseline: 0pt,
273
- )[
274
- #set align(left + horizon)
275
- #text(size: title-size(p.drawn), weight: 600, style: if p.tour.school { "italic" } else { "normal" })[
276
- #p.tour.title
277
- ]
278
- #linebreak()
279
- #text(size: 5.8pt, fill: mute)[
280
- #span-label(p.span)#if p.held [ #sym.dagger] · #p.tour.vml#if p.clipped [ · runs past #(start-year + years - 1)]
281
- ]
282
- ]
447
+ block-text(
448
+ p.tour.title,
449
+ range(notes.len(), 0, step: -1).map(n => notes.slice(0, n).join[ · ]),
450
+ style: if p.tour.school { "italic" } else { "normal" },
451
+ ),
452
+ )
283
453
  ])
284
- at = p.start + p.drawn
285
- idx += 1
454
+ at = p.from + p.drawn
286
455
  }
287
- if at < cols { rows.push(table.cell(colspan: cols - at, stroke: top-rule)[]) }
456
+ rows += empty(cols - at, stroke: top-rule)
288
457
  }
289
458
 
290
- #table(
291
- columns: (1.2in,) + (1fr,) * cols,
292
- rows: (auto, auto) + (band-height,) * vectors.len(),
459
+ // The years are ruled from the gates down, so a block is read against the gate
460
+ // above it without a straightedge. A rule stops where a block crosses it: the
461
+ // block is in front of the year, not cut by it. On a blank chart the rules are
462
+ // the grid a pen draws the vectors on. The window's far side is left open,
463
+ // because the career is not over where the chart stops.
464
+ #block(above: 18pt, table(
465
+ columns: (rail,) + (1fr,) * cols,
466
+ rows: (auto,) * (1 + constraints.len() + lanes) + (band-height,) * vectors.len(),
293
467
  stroke: none,
294
468
  inset: 2pt,
469
+ ..range(years).map(i => table.vline(x: 1 + 2 * i, start: 1 + constraints.len(), stroke: 0.4pt + hair)),
295
470
  ..rows,
296
- )
297
-
298
- // The legend names the marks this chart actually carries and no others.
299
- #let drawn-tours = vectors.map(v => place(v.tours).filter(p => not p.beyond)).flatten()
300
- #let shown-tiers = ladder.filter(m => in-window(board-yg + m.at(0))).map(m => m.at(2)).dedup()
301
- #let legend = ()
302
- #if "board" in shown-tiers { legend.push([#chip("board", "board") ranked against your year group]) }
303
- #if "look" in shown-tiers { legend.push([#chip("look", "look") one of a numbered series]) }
304
- #if drawn-tours.any(p => not p.tour.school) {
305
- legend.push([#box(width: 14pt, height: 6pt, fill: rank-tints.at(0), stroke: 0.5pt + luma(55%)) assignment])
306
- }
307
- #if drawn-tours.any(p => p.tour.school) {
308
- legend.push([#box(width: 14pt, height: 6pt, stroke: (paint: mute, thickness: 0.5pt, dash: (2pt, 1.5pt))) school])
309
- }
310
- #if drawn-tours.any(p => p.held) { legend.push([#sym.dagger held to the next cycle]) }
471
+ table.hline(stroke: 0.4pt + hair),
472
+ ))
311
473
 
312
- #if legend.len() > 0 {
313
- v(3pt)
314
- text(size: 5.8pt, fill: mute, legend.join(h(6pt)))
474
+ // The gates and tours carry their own names. The legend keys only the marks
475
+ // that cannot, and only those this chart carries, beside a count of the gates
476
+ // past the window's far side.
477
+ #let drawn-tours = vectors.map(v => v.placed.filter(p => not p.beyond)).flatten()
478
+ #let swatch(..args) = box(width: 14pt, height: 6pt, baseline: 1pt, ..args)
479
+ #let key(mark, name) = [#mark#h(3pt)#name]
480
+ #let legend = (
481
+ if current-span > 0 { key(swatch(stroke: current-stroke), [current assignment]) },
482
+ if drawn-tours.any(p => p.tour.school) { key(swatch(stroke: school-stroke), [school]) },
483
+ if drawn-tours.any(p => p.tour.out-of-range) { key(sym.dagger, [length outside 0.5–4 yr]) },
484
+ ).filter(k => k != none)
485
+ #if legend.len() > 0 or ahead > 0 {
486
+ block(above: 5pt, pad(left: rail, grid(
487
+ columns: (1fr, auto),
488
+ text(size: micro, fill: mute, legend.join(h(12pt), default: [])),
489
+ if ahead > 0 { text(size: micro, fill: mute)[#ahead more milestone#if ahead > 1 [s] past #window-end] },
490
+ )))
315
491
  }
316
492
 
317
- #v(9pt)
318
-
319
493
  // ═══ RECORD ══════════════════════════════════════════════════════════════════
320
- // Every member arrives, held or not, in the order Quill.yaml declares it; an
321
- // unheld member's detail arrives blank whatever the document retains.
322
- #let quals = data.qualifications
494
+ // The record that makes the plan credible, a row to a part: one with nothing
495
+ // in it goes rather than printing empty. Every first line in it hangs from the
496
+ // top of its row by one edge, so a label and its row share a baseline.
497
+ #let row-top = 5pt
498
+
499
+ // A matrix arrives as the members it holds; `roster` gives back the whole
500
+ // vocabulary in the order Quill.yaml declares it, held or not. The labels repeat
501
+ // each matrix's `title`, which the data does not carry. Every member prints: the
502
+ // open boxes say what has not been done yet as plainly as the filled ones say
503
+ // what has. What is held leads its row, and a member holds together on its line.
323
504
  #let vocabulary = (
324
- ("Leadership & Command", quals.command),
325
- ("Operations", quals.operations),
326
- ("Staff & Functional", quals.staff),
327
- ("Education & PME", quals.education),
505
+ ("Leadership & command", roster(quals, "command")),
506
+ ("Operations", roster(quals, "operations")),
507
+ ("Staff & functional", roster(quals, "staff")),
508
+ ("Education & PME", roster(quals, "education")),
328
509
  )
329
-
330
- #let remarks = (
331
- ("Awards", data.awards),
332
- ("Certifications", data.certifications),
333
- ("Deployments", data.deployments),
334
- ).filter(r => r.at(1).len() > 0)
335
-
336
- // The stratifications sit beside the vocabulary; with none, there is no narrow
337
- // empty column beside it, there is no column.
338
- #let strats-column = [
339
- #section-rule("Recent stratifications")
340
- #for s in data.stratifications {
341
- block(spacing: 7pt)[
342
- #grid(columns: (0.44in, 1fr), column-gutter: 7pt, align: (right + top, left + top),
343
- text(size: 8.5pt, weight: 700)[#s.year],
344
- [
345
- #if not blank(s.rater_strat) [#text(size: 8pt)[#s.rater_strat]]
346
- #if not blank(s.rater_strat) and not blank(s.hlr_strat) [#linebreak()]
347
- #if not blank(s.hlr_strat) [#text(size: 7.4pt, fill: mute)[#s.hlr_strat]]
348
- ],
349
- )
350
- ]
351
- }
510
+ #let member(m) = box[
511
+ #let detail = if m.held { m.value.detail } else { "" }
512
+ #if m.held { held-mark } else { open-mark }#h(3pt)#text(
513
+ weight: if m.held { 600 } else { 400 },
514
+ fill: if m.held { ink } else { mute },
515
+ m.title,
516
+ )#if not blank(detail) [#h(2pt)#text(size: micro, style: "italic", fill: mute, trim(detail))]
352
517
  ]
518
+ #let members-row(rows) = {
519
+ (rows.filter(m => m.held) + rows.filter(m => not m.held)).map(member).join([#h(10pt) ])
520
+ }
353
521
 
354
- #let vocabulary-column = [
355
- #section-rule("Qualifications & experience")
356
- #grid(
357
- columns: (1fr,) * vocabulary.len(),
358
- column-gutter: 14pt,
359
- ..vocabulary.map(group => {
360
- let (heading, members) = group
361
- [
362
- #text(size: 6.2pt, weight: 700, tracking: 0.4pt, fill: mute)[#upper(heading)]
363
- #v(3pt)
364
- #set par(leading: 0.42em)
365
- #for (_, m) in members {
366
- block(spacing: 0pt, inset: (y: 1.1pt))[
367
- #grid(columns: (7.6pt, 1fr), align: (left + top, left + top))[
368
- #if m.held { held-mark } else { open-mark }
369
- ][
370
- #text(
371
- size: 7.2pt,
372
- weight: if m.held { 600 } else { 400 },
373
- fill: if m.held { ink } else { luma(50%) },
374
- )[#m.title]
375
- #if not blank(m.detail) [
376
- #linebreak()
377
- #text(size: 6.4pt, style: "italic", fill: mute)[#trim(m.detail)]
378
- ]
379
- ]
380
- ]
381
- }
382
- ]
383
- }),
384
- )
385
- ]
522
+ // A table, so each rater's column reads down the years. With no Higher Level
523
+ // Reviewer strat on any row, that column goes rather than printing empty.
524
+ #let strats = data.stratifications.sorted(key: s => -s.year)
525
+ #let any-hlr = strats.any(s => not blank(s.hlr))
526
+ #let strat-cell(v) = if blank(v) { dash } else { trim(v) }
527
+ #let strats-table = table(
528
+ columns: (auto,) * (if any-hlr { 3 } else { 2 }),
529
+ column-gutter: 16pt,
530
+ inset: (_, y) => (x: 0pt, top: if y == 0 { 0pt } else { 2pt }, bottom: 2pt),
531
+ stroke: none,
532
+ table.header([], eyebrow[Rater], ..if any-hlr { (eyebrow[HLR],) }),
533
+ ..strats.map(s => (
534
+ text(size: base, weight: 700)[#s.year],
535
+ text(size: base, strat-cell(s.rater)),
536
+ ..if any-hlr { (text(size: base, strat-cell(s.hlr)),) },
537
+ )).flatten(),
538
+ )
386
539
 
387
- #if data.stratifications.len() > 0 {
388
- grid(columns: (2.45in, 1fr), column-gutter: 24pt, strats-column, vocabulary-column)
389
- } else {
390
- vocabulary-column
391
- }
540
+ // The two things a board reads first, the stratifications and the awards,
541
+ // lead; the experience follows.
542
+ #let listed(name, lines) = if lines.len() > 0 { (([#name], lines.map(trim).join[ · ]),) }
543
+ #let record = (
544
+ ..if strats.len() > 0 { (([Stratifications], strats-table),) },
545
+ ..listed("Awards", data.awards),
546
+ ..vocabulary.map(((name, members)) => ([#name], members-row(members))),
547
+ ..listed("Certifications", data.certifications),
548
+ ..listed("Deployments", data.deployments),
549
+ )
392
550
 
393
- // Three lists side by side across the full width. Stacked in a narrow column
394
- // they cost three times the height and read as one long list.
395
- #if remarks.len() > 0 [
396
- #v(8pt)
397
- #section-rule("Awards · certifications · deployments")
398
- #grid(
399
- columns: (1fr,) * 3,
400
- column-gutter: 24pt,
401
- ..remarks.map(((label, lines)) => [
402
- #text(size: 5.8pt, weight: 600, tracking: 0.4pt, fill: mute)[#upper(label)]
403
- #v(2pt)
404
- #for l in lines [#block(spacing: 2.5pt)[#text(size: 7.6pt)[#l]]]
405
- ]),
551
+ #divider(14pt)
552
+ #{
553
+ set text(size: small, top-edge: row-top)
554
+ grid(
555
+ columns: (rail, 1fr),
556
+ row-gutter: 7pt,
557
+ ..record.map(((name, body)) => (rail-label(name), body)).flatten(),
406
558
  )
407
- ]
559
+ }
408
560
 
409
561
  // ═══ NOTES ═══════════════════════════════════════════════════════════════════
410
562
  // The one thing the page does that the browser tool it came from could not: a
411
563
  // rater writes on it during the discussion, and the sheet becomes the record of
412
- // the conversation. So the lines print whether or not anything was typed above
413
- // them.
414
- #v(9pt)
415
- #section-rule("Development notes")
564
+ // the conversation. So the ruling prints whether or not anything was typed, the
565
+ // typed notes are set on it, and it runs to the foot of the page: whatever the
566
+ // record above leaves is room to write.
567
+ #divider(12pt)
568
+
569
+ // Narrow rule, a quarter inch: the tightest a pen writes on comfortably.
570
+ #let pitch = 18pt
571
+ #let baseline-drop = 13pt
572
+ #let rule-drop = baseline-drop + 2.5pt
573
+ #let ruling(n) = for k in range(n) {
574
+ place(top + left, dy: k * pitch + rule-drop, line(length: 100%, stroke: 0.4pt + hair))
575
+ }
576
+
577
+ // Every line box is exactly one pitch tall with its baseline at the same drop,
578
+ // so the typed notes land on the rules however many lines they run to, and the
579
+ // rail's label sits on the first.
416
580
  #let typed = data.at("$body", default: "")
417
- #if type(typed) != str [
418
- #block(spacing: 6pt)[#text(size: 7.6pt)[#typed]]
419
- ]
420
- #for _ in range(2) {
421
- v(11pt)
422
- line(length: 100%, stroke: 0.4pt + luma(80%))
581
+ #let on-ruling(body) = {
582
+ set text(top-edge: baseline-drop, bottom-edge: baseline-drop - pitch)
583
+ set par(leading: 0pt, spacing: 0pt)
584
+ body
423
585
  }
424
- #v(8pt)
425
- #text(size: 6.2pt, fill: mute)[
426
- Discussed with #box(width: 1.7in, stroke: (bottom: 0.4pt + luma(60%)))[]
427
- #h(10pt) on #box(width: 1in, stroke: (bottom: 0.4pt + luma(60%)))[]
428
- ]
586
+
587
+ // What was typed and two blank lines under it are the minimum, and hold their
588
+ // height: a record too long for the page pushes it onto a second rather than
589
+ // eating the lines a rater writes on.
590
+ #grid(
591
+ columns: (rail, 1fr),
592
+ on-ruling(rail-label[Notes]),
593
+ layout(size => {
594
+ let notes = if type(typed) != str { on-ruling(typed) } else { [] }
595
+ let used = calc.round(measure(block(width: size.width, notes)).height / pitch)
596
+ let n = int(used) + 2
597
+ block(width: 100%, height: n * pitch, { ruling(n); notes })
598
+ }),
599
+ )
600
+ #block(width: 100%, height: 1fr, pad(left: rail, layout(size => ruling(calc.floor((size.height - rule-drop) / pitch) + 1))))
601
+
602
+ #block(above: 10pt, grid(
603
+ columns: (rail, 1fr),
604
+ rail-label[Discussed with],
605
+ text(size: micro, fill: mute)[
606
+ #box(width: 2.5in, stroke: (bottom: 0.4pt + mute))[]
607
+ #h(12pt) on #box(width: 1.2in, stroke: (bottom: 0.4pt + mute))[]
608
+ ],
609
+ ))