@airmark/quiver 0.34.0 → 0.35.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 (20) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/quills/classic_resume/{0.0.1 → 0.1.0}/Quill.yaml +11 -11
  4. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/components.typ +21 -12
  5. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/config.typ +6 -5
  6. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/layout.typ +3 -2
  7. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/util.typ +17 -4
  8. package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/typst.toml +0 -5
  9. package/quills/classic_resume/{0.0.1 → 0.1.0}/plate.typ +16 -9
  10. package/quills/{cyber_ribbon_chart/0.0.1 → cyber_career_plan/0.1.0}/Quill.yaml +8 -7
  11. package/quills/{cyber_ribbon_chart/0.0.1 → cyber_career_plan/0.1.0}/example.md +1 -1
  12. package/quills/{cyber_ribbon_chart/0.0.1 → cyber_career_plan/0.1.0}/plate.typ +127 -55
  13. /package/quills/classic_resume/{0.0.1 → 0.1.0}/example.md +0 -0
  14. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/LICENSE +0 -0
  15. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
  16. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
  17. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
  18. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
  19. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +0 -0
  20. /package/quills/classic_resume/{0.0.1 → 0.1.0}/packages/ttq-classic-resume/src/lib.typ +0 -0
package/README.md CHANGED
@@ -16,8 +16,8 @@ and aligned to the current Quillmark spec.
16
16
  | `daf1206` | 0.1.0 | DAF Form 1206 — Nomination for Award |
17
17
  | `daf4392` | 0.1.0 | DAF Form 4392 — Pre-Departure Safety Briefing (Page 2) |
18
18
  | `afmc_moa` | 0.0.1 | DoD Memorandum of Agreement (DoDI 4000.19) |
19
- | `classic_resume` | 0.0.1 | Classic single-page resume |
20
- | `cyber_ribbon_chart` | 0.0.1 | 17X Cyber Operations Officer ribbon chart and career vector planner |
19
+ | `classic_resume` | 0.1.0 | Classic single-page resume |
20
+ | `cyber_career_plan` | 0.1.0 | 17X Cyber Operations Officer career plan: current assignment, vectors and record |
21
21
 
22
22
  ## Install
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Airmark Quiver — Air Force and DAF document Quills for Quillmark.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  quill:
2
2
  name: classic_resume
3
- version: 0.0.1
3
+ version: 0.1.0
4
4
  backend: typst
5
5
  description: Classic single-page resume
6
6
 
@@ -75,11 +75,11 @@ card_kinds:
75
75
  type: plaintext
76
76
  inline: true
77
77
  default: Summary
78
- description: "Printed in uppercase over a rule. An empty title prints no heading, which sets the summary straight under your name."
78
+ description: "Printed in uppercase over a rule. An empty title prints no heading, so a summary that is the first card sits straight under your name."
79
79
 
80
80
  experience:
81
81
  title: Experience
82
- description: "Jobs, most recent first: employer and dates on the first line, role and location on the second, details under them."
82
+ description: "Jobs, most recent first: employer and dates on the first line, role and location on the second, details under them. Prose written under the card prints under the heading, ahead of the jobs."
83
83
  seed:
84
84
  jobs:
85
85
  - company: Employer
@@ -122,11 +122,11 @@ card_kinds:
122
122
  type: richtext
123
123
  default: ""
124
124
  description: "Set under the entry. Usually a bulleted list, one line each and strongest first; a sentence, *italics* and **bold** set as well, and a heading sets as a bold line. Leave blank for nothing under the entry."
125
- description: "One row per job. A row is never split across a page break."
125
+ description: "One row per job. A row is kept to one page, unless it is taller than a page."
126
126
 
127
127
  education:
128
128
  title: Education
129
- description: "Schools, most recent first: school and date on the first line, degree and location on the second."
129
+ description: "Schools, most recent first: school and date on the first line, degree and location on the second. Prose written under the card prints under the heading, ahead of the schools."
130
130
  seed:
131
131
  schools:
132
132
  - school: School
@@ -162,7 +162,7 @@ card_kinds:
162
162
 
163
163
  skills:
164
164
  title: Skills
165
- description: "Skills in labelled groups, filled across the columns and then down."
165
+ description: "Skills in labelled groups, filled across the columns and then down. Prose written under the card prints under the heading, ahead of the groups."
166
166
  seed:
167
167
  skills:
168
168
  - label: Category
@@ -183,7 +183,7 @@ card_kinds:
183
183
  label:
184
184
  type: plaintext
185
185
  inline: true
186
- description: "Set in bold, with its items on the line under it."
186
+ description: "Set in bold, with its items on the line under it. Leave blank to print the items alone."
187
187
  items:
188
188
  type: plaintext
189
189
  inline: true
@@ -198,7 +198,7 @@ card_kinds:
198
198
 
199
199
  projects:
200
200
  title: Projects
201
- description: "Projects: name and link on the first line, details under them."
201
+ description: "Projects: name and link on the first line, details under them. Prose written under the card prints under the heading, ahead of the projects."
202
202
  seed:
203
203
  projects:
204
204
  - name: Project
@@ -223,13 +223,13 @@ card_kinds:
223
223
  link:
224
224
  type: string
225
225
  default: ""
226
- description: "Where to see it, set small and italic at the right as written: github.com/you/project reads better than its https:// form. A web address becomes a live link; anything else, such as 'closed source', prints as written. Leave blank to omit."
226
+ description: "Where to see it, set small and italic at the right as written: github.com/you/project reads better than its https:// form. A web address, an email address or a phone number becomes a live link; anything else, such as 'closed source', prints as written. Leave blank to omit."
227
227
  details: *details
228
228
  description: "One row per project."
229
229
 
230
230
  certifications:
231
231
  title: Certifications
232
- description: "A list filled across the columns and then down. Retitled, it holds any short items: awards, languages, clearances."
232
+ description: "A list filled across the columns and then down. Retitled, it holds any short items: awards, languages, clearances. Prose written under the card prints under the heading, ahead of the list."
233
233
  seed:
234
234
  items:
235
235
  - Certification (ABBREVIATION)
@@ -249,7 +249,7 @@ card_kinds:
249
249
 
250
250
  other:
251
251
  title: Other section
252
- description: "A section the kinds above do not name — volunteer work, military service, publications, a competition. Rows take the Experience shape with neutral names."
252
+ description: "A section the kinds above do not name — volunteer work, military service, publications, a competition. Rows take the Experience shape with neutral names. Prose written under the card prints under the heading, ahead of the rows."
253
253
  seed:
254
254
  title: Section Title
255
255
  entries:
@@ -23,18 +23,25 @@
23
23
 
24
24
  // Shared skeleton of `entry`: a grid of left/right aligned header cells
25
25
  // followed by an optional bulleted body, kept together on one page. The gutter
26
- // keeps a left cell that wraps off the right one.
27
- #let _entry(cfg, cells, body) = block(
28
- above: cfg.leading + cfg.rule-spacing,
29
- below: cfg.leading + cfg.entry-spacing,
30
- breakable: false,
31
- {
26
+ // keeps a left cell that wraps off the right one. An entry taller than a page
27
+ // cannot be kept together, so it breaks where it must rather than running off
28
+ // the foot of one.
29
+ #let _entry(cfg, cells, body) = {
30
+ let inner = {
32
31
  grid(columns: (1fr, auto), column-gutter: 1em, row-gutter: cfg.leading, ..cells)
33
32
  if body != none {
34
33
  _bulleted(cfg, body)
35
34
  }
36
- },
37
- )
35
+ }
36
+ block(
37
+ above: cfg.leading + cfg.rule-spacing,
38
+ below: cfg.leading + cfg.entry-spacing,
39
+ layout(region => block(
40
+ breakable: measure(block(width: region.width, inner)).height > region.height,
41
+ inner,
42
+ )),
43
+ )
44
+ }
38
45
 
39
46
  // Sets the contacts on as few lines as hold them, a separator between two on
40
47
  // one line. A line therefore breaks only between contacts, and never starts or
@@ -92,7 +99,7 @@
92
99
  if named or contacts.len() > 0 {
93
100
  with-config(cfg => block(below: cfg.leading + cfg.header-spacing, {
94
101
  if named {
95
- heading(level: 1, name)
102
+ [#heading(level: 1, name) <ttq-classic-resume-name>]
96
103
  }
97
104
  if contacts.len() > 0 {
98
105
  block(
@@ -164,7 +171,7 @@
164
171
  ///
165
172
  /// Two shapes are accepted and told apart by the first item: a flat array of
166
173
  /// content, or an array of `(label: .., text: ..)` dictionaries, which puts
167
- /// the label in bold above its text.
174
+ /// the label in bold above its text. A `none` or empty label prints no line.
168
175
  #let item-grid(items: (), columns: 2) = {
169
176
  if items.len() == 0 {
170
177
  return
@@ -183,8 +190,10 @@
183
190
  + "when the first one is, found " + repr(item),
184
191
  )
185
192
  block({
186
- text(weight: "bold", item.label)
187
- linebreak()
193
+ if item.label not in (none, "", []) {
194
+ text(weight: "bold", item.label)
195
+ linebreak()
196
+ }
188
197
  item.text
189
198
  })
190
199
  } else {
@@ -28,10 +28,11 @@
28
28
 
29
29
  // Resume header. The separator stands between two contacts on one line,
30
30
  // never at either end of one, with a word space and this padding on each
31
- // side of it.
32
- name-size: 18pt,
31
+ // side of it. Sizes in `em` follow the body size: at 12pt, the name is set
32
+ // at 18pt and the separator at 7pt.
33
+ name-size: 1.5em,
33
34
  contact-separator: "❖",
34
- contact-separator-size: 7pt,
35
+ contact-separator-size: 7em / 12,
35
36
  contact-separator-padding: 0.25em,
36
37
 
37
38
  // Rule drawn underneath a section header.
@@ -39,8 +40,8 @@
39
40
 
40
41
  // Square bullet used by lists inside an entry. The negative baseline lifts
41
42
  // the square off the baseline so that it lines up with the middle of the
42
- // x-height instead of hanging below the line.
43
- marker-size: 3.5pt,
43
+ // x-height instead of hanging below the line. At 12pt the square is 3.5pt.
44
+ marker-size: 3.5em / 12,
44
45
  marker-baseline: -0.07em,
45
46
  marker-indent: 0.8em,
46
47
 
@@ -5,13 +5,14 @@
5
5
 
6
6
  // The name at the left and the page count at the right, over every page after
7
7
  // the first, so a page read apart from the first still says whose it is. The
8
- // name is read back off the document's level 1 heading. Small and italic, it
8
+ // name is read back off the heading `resume-header` labels, so a level 1
9
+ // heading written in the body never stands in for it. Small and italic, it
9
10
  // reads as a running head: a 0.5in margin leaves no room to set it further off
10
11
  // the page.
11
12
  #let _continuation-header(cfg) = context {
12
13
  if counter(page).get().first() > 1 {
13
14
  set text(size: cfg.annotation-size, style: "italic")
14
- let names = query(heading.where(level: 1))
15
+ let names = query(<ttq-classic-resume-name>)
15
16
  if names.len() > 0 { names.first().body }
16
17
  h(1fr)
17
18
  counter(page).display("1 of 1", both: true)
@@ -4,8 +4,10 @@
4
4
  // A contact line usually holds an email address, a profile link, a phone
5
5
  // number and a city. Only the first three are worth turning into a link, and
6
6
  // they are distinguishable by shape.
7
+ #let _mailto = regex("^(?i:mailto):\\S+$")
8
+ #let _tel = regex("^(?i:tel):[+(\\d][\\d\\s()./+-]*\\d$")
7
9
  #let _email = regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
8
- #let _scheme = regex("^(https?://|www\\.)\\S+$")
10
+ #let _scheme = regex("^(?i:https?://|www\\.)\\S+$")
9
11
  #let _domain = regex("^[\\w-]+(\\.[\\w-]+)*\\.[A-Za-z]{2,24}(/\\S*)?$")
10
12
  #let _phone = regex("^[+(\\d][\\d\\s()./+-]*\\d$")
11
13
  #let _digit = regex("\\d")
@@ -13,7 +15,9 @@
13
15
  /// Turns a contact into a link when it looks like an email address, a web
14
16
  /// address or a phone number, and returns it unchanged otherwise. A city or a
15
17
  /// clearance level is therefore left alone, and so is anything that is already
16
- /// content, so callers can always pass their own `link(..)` instead.
18
+ /// content, so callers can always pass their own `link(..)` instead. One
19
+ /// already written as a `mailto:`, `tel:` or `http(s)://` link keeps its
20
+ /// target, whatever case its scheme is written in.
17
21
  ///
18
22
  /// `body` is what prints, the contact itself unless given: a caller holding the
19
23
  /// contact as text and as content of its own reads the one and prints the
@@ -22,11 +26,20 @@
22
26
  let body = if body == auto { contact } else { body }
23
27
  if type(contact) != str {
24
28
  body
29
+ } else if contact.match(_mailto) != none {
30
+ link("mailto:" + contact.slice(7), body)
31
+ } else if contact.match(_tel) != none {
32
+ link("tel:" + contact.slice(4).replace(regex("[^\\d+]"), ""), body)
25
33
  } else if contact.match(_email) != none {
26
34
  link("mailto:" + contact, body)
27
35
  } else if contact.match(_scheme) != none {
28
- let href = if contact.starts-with("www.") { "https://" + contact } else { contact }
29
- link(href, body)
36
+ let (scheme, rest) = if lower(contact).starts-with("www.") {
37
+ ("https://", contact)
38
+ } else {
39
+ let split = contact.position("://") + 3
40
+ (lower(contact.slice(0, split)), contact.slice(split))
41
+ }
42
+ link(scheme + rest, body)
30
43
  } else if contact.match(_domain) != none {
31
44
  link("https://" + contact, body)
32
45
  } else if contact.match(_phone) != none and contact.matches(_digit).len() >= 7 {
@@ -19,8 +19,3 @@ keywords = [
19
19
  ]
20
20
  categories = ["cv"]
21
21
  disciplines = ["business", "computer-science", "engineering", "communication"]
22
-
23
- [template]
24
- path = "template"
25
- entrypoint = "resume.typ"
26
- thumbnail = "thumbnail.png"
@@ -37,7 +37,8 @@
37
37
  // no content. What prints is the field's ink twin, `printed`, which keeps the
38
38
  // click target a value computed with loses.
39
39
  #let linked(value, printed) = {
40
- if value.trim() == "" { none } else { auto-link(value, body: printed) }
40
+ let value = value.trim()
41
+ if value == "" { none } else { auto-link(value, body: printed) }
41
42
  }
42
43
 
43
44
  #show: resume.with(
@@ -53,9 +54,9 @@
53
54
  // EB Garamond has no ❖ (U+2756), the package's default separator: it prints
54
55
  // only where a second family is installed to lend the glyph, and draws from
55
56
  // that family rather than this one. ◆ is the nearest diamond EB Garamond
56
- // carries itself, and wants a point less to weigh the same.
57
+ // carries itself, and wants a point less to weigh the same: 6pt at 12pt.
57
58
  contact-separator: "◆",
58
- contact-separator-size: 6pt,
59
+ contact-separator-size: 0.5em,
59
60
  )
60
61
 
61
62
  // The package restyles lists to its square bullet inside an entry only. A
@@ -100,22 +101,28 @@
100
101
  body: under-entry(details),
101
102
  )
102
103
 
104
+ // The rows an author filled in. A row left wholly blank, as a new one is, would
105
+ // print as an empty gap.
106
+ #let filled(rows) = rows.filter(row => row.values().any(value => {
107
+ or-none(if type(value) == str { value.trim() } else { value }) != none
108
+ }))
109
+
103
110
  // What each declared kind sets under its heading and body. A card of a kind
104
111
  // the quill does not declare is warned on and left off the page: its fields
105
112
  // are not this quill's to read.
106
113
  #let rows = (
107
114
  summary: card => none,
108
- experience: card => for job in card.jobs {
115
+ experience: card => for job in filled(card.jobs) {
109
116
  dated(job.company, job.dates, job.role, job.location, job.details)
110
117
  },
111
- education: card => for school in card.schools {
118
+ education: card => for school in filled(card.schools) {
112
119
  dated(school.school, school.dates, school.degree, school.location, school.details)
113
120
  },
114
121
  skills: card => item-grid(
115
- items: card.skills.map(row => (label: trim-inline(row.label), text: trim-inline(row.items))),
122
+ items: filled(card.skills).map(row => (label: or-none(row.label), text: trim-inline(row.items))),
116
123
  columns: calc.max(1, card.columns),
117
124
  ),
118
- projects: card => for project in card.projects {
125
+ projects: card => for project in filled(card.projects) {
119
126
  entry(
120
127
  heading: trim-inline(project.name),
121
128
  form: "linked",
@@ -124,10 +131,10 @@
124
131
  )
125
132
  },
126
133
  certifications: card => item-grid(
127
- items: card.items.map(trim-inline),
134
+ items: card.items.map(or-none).filter(item => item != none),
128
135
  columns: calc.max(1, card.columns),
129
136
  ),
130
- other: card => for row in card.entries {
137
+ other: card => for row in filled(card.entries) {
131
138
  dated(row.heading, row.dates, row.subtitle, row.location, row.details)
132
139
  },
133
140
  )
@@ -1,8 +1,8 @@
1
1
  quill:
2
- name: cyber_ribbon_chart
3
- version: 0.0.1
2
+ name: cyber_career_plan
3
+ version: 0.1.0
4
4
  backend: typst
5
- description: 17X Cyber Operations Officer ribbon chart and career vector planner
5
+ description: "17X Cyber Operations Officer career plan: current assignment, vectors and record"
6
6
 
7
7
  typst:
8
8
  plate_file: plate.typ
@@ -112,7 +112,7 @@ main:
112
112
  ui:
113
113
  group: plan
114
114
  compact: true
115
- description: "How many years wide the timeline is. Twelve carries a Captain from today past the Lt Col board and is the comfortable reading width. The full milestone ladder runs to YG+17, so widening it toward eighteen brings the SDE looks into view at the cost of narrower columns; any milestone that falls outside the window is counted in a note rather than dropped."
115
+ description: "How many years wide the timeline is, at most eighteen; a wider window draws at eighteen. Twelve carries a Captain from today past the Lt Col board and is the comfortable reading width. The full milestone ladder runs to YG+17, so widening it toward eighteen brings the SDE looks into view at the cost of narrower columns. A milestone past the window's far end is counted in a note rather than dropped; one before it is history, except a course passed unticked, which is listed as overdue."
116
116
 
117
117
  move_year:
118
118
  type: integer?
@@ -180,7 +180,7 @@ main:
180
180
  type: number
181
181
  default: 2
182
182
  title: Years
183
- description: "Length in years, in halves: 0.5 to 4. The timeline is ruled in half-years, one per VML cycle, so a length is rounded to the nearest half, and one outside that range draws at the nearer end, marked with a dagger. A whole number of years keeps you on the cycle you moved in on; a half switches Summer to Winter or back."
183
+ description: "Length in years, in halves: 0.5 to 4. The timeline is ruled in half-years, one per VML cycle, so a length is rounded to the nearest half first, and one that rounds to outside that range draws at the nearer end, marked with a dagger. A whole number of years keeps you on the cycle you moved in on; a half switches Summer to Winter or back."
184
184
  school:
185
185
  type: boolean
186
186
  default: false
@@ -258,11 +258,12 @@ main:
258
258
  type: object
259
259
  ui:
260
260
  group: qualifications
261
+ layout: flat
261
262
  description: "Tick what you hold. The page prints the whole 17X vocabulary and marks these: a rater reads the empty boxes as readily as the filled ones, so what you have not done yet is on the page whether you tick it or not. A ticked course or school also marks its milestone on the timeline done. Written out, a tick is `flight_cc: true`, and a tick with its detail `flight_cc: {detail: UMS Flt/CC}`; an unticked member is left out."
262
263
  properties:
263
264
  command:
264
265
  type: matrix
265
- title: Leadership & Command
266
+ title: Leadership & command
266
267
  ui:
267
268
  compact: true
268
269
  members:
@@ -295,7 +296,7 @@ main:
295
296
  properties: *detail
296
297
  staff:
297
298
  type: matrix
298
- title: Staff & Functional
299
+ title: Staff & functional
299
300
  ui:
300
301
  compact: true
301
302
  members:
@@ -1,5 +1,5 @@
1
1
  ~~~
2
- $quill: cyber_ribbon_chart
2
+ $quill: cyber_career_plan
3
3
  name: Capt John A. Snuffy
4
4
  afsc: 17S3
5
5
  duty_title: Instructor
@@ -29,9 +29,10 @@
29
29
  #set block(spacing: 0pt)
30
30
  #set par(spacing: 0pt)
31
31
 
32
+ #let officer = data.at("name", default: "").trim()
32
33
  #set document(
33
- title: "Ribbon Chart — " + data.name,
34
- author: data.name,
34
+ title: if officer == "" { "Career Plan" } else { "Career Plan — " + officer },
35
+ author: officer,
35
36
  )
36
37
 
37
38
  // A `plaintext` field lowers to content padded with a space on each side, which
@@ -75,13 +76,19 @@
75
76
  #let board-yg = if adjusted { adj-yg } else { comm-yg }
76
77
  // A required year not yet filled in arrives as 0. With no start year the window
77
78
  // 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
+ // from YG+0 and prints no calendar years rather than the years 0 to 11. With a
80
+ // start year but no year group there is nothing to count the ladder from, so
81
+ // the axis prints calendar years alone and no milestone is drawn.
79
82
  #let start-year = if data.timeline_start_year == 0 { board-yg } else { data.timeline_start_year }
80
83
  #let dated = start-year != 0
81
- #let years = calc.max(1, data.timeline_years)
84
+ #let laddered = board-yg != 0 or not dated
85
+ #let yg-offset(k) = if k < 0 [YG#k] else [YG+#k]
86
+ // Eighteen is the widest the marks hold their columns at, and the schema cannot
87
+ // bound an integer, so a wider window draws at eighteen.
88
+ #let years = calc.clamp(data.timeline_years, 1, 18)
82
89
  #let cols = years * 2
83
90
  #let last-year = start-year + years - 1
84
- #let window-end = if dated [#last-year] else [YG+#(last-year - board-yg)]
91
+ #let window-end = if dated [#last-year] else { yg-offset(last-year - board-yg) }
85
92
  #let in-window(y) = y >= start-year and y <= last-year
86
93
  #let band-height = 0.46in
87
94
  // Marks fit their year column: past twelve years they step off the scale.
@@ -124,12 +131,16 @@
124
131
  // A course whose year has passed unticked is what a rater most needs to see, so
125
132
  // it stands before the window, in the rail, saying when it was due. A passed
126
133
  // board is history.
127
- #let gates = gates.map(g => (..g, overdue: not g.board and not g.done and g.year < start-year))
134
+ #let gates = if laddered {
135
+ gates.map(g => (..g, overdue: not g.board and not g.done and g.year < start-year))
136
+ } else { () }
128
137
  #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)))
138
+ #let windows = if laddered {
139
+ (
140
+ (yg: 8, name: "IDE", by: ("ide_candidate", "ide_graduate")),
141
+ (yg: 14, name: "SDE", by: ("sde_candidate", "sde_graduate")),
142
+ ).map(w => (..w, done: held(w.by), years: looks.enumerate().map(((k, _)) => board-yg + w.yg + k)))
143
+ } else { () }
133
144
  #let ahead = (
134
145
  gates.filter(g => not g.done and g.year > last-year).len()
135
146
  + windows.filter(w => not w.done).map(w => w.years.filter(y => y > last-year).len()).sum(default: 0)
@@ -216,14 +227,18 @@
216
227
  #let vectors = data.vectors.enumerate().map(((i, v)) => (
217
228
  rank: ranks.at(i, default: "Vector " + str(i + 1)),
218
229
  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
- ))),
230
+ // A length is rounded to the half-year first; one that rounds outside 0.5 to
231
+ // 4 draws at the nearer end under a dagger the legend explains, since the
232
+ // schema cannot bound a number.
233
+ placed: lay(v.tours.map(t => {
234
+ let halves = int(calc.round(float(t.years) * 2))
235
+ (
236
+ title: trim(t.title),
237
+ span: calc.clamp(halves, 1, 8),
238
+ out-of-range: halves < 1 or halves > 8,
239
+ school: t.school,
240
+ )
241
+ })),
227
242
  ))
228
243
 
229
244
  // The band is ruled to an even height, so a block cannot grow to fit its text:
@@ -259,6 +274,24 @@
259
274
  )).flatten().find(s => s != none))
260
275
  })
261
276
 
277
+ // A vector's label holds to its row the way a block's text does: it steps down
278
+ // the scale until it fits, every word within the rail. One too long at the
279
+ // smallest drops its rank, which the row's place already says, and keeps the
280
+ // whole lines the row holds.
281
+ #let track-label(name, sub) = layout(size => {
282
+ let (w, h) = (size.width, size.height)
283
+ let words = plain(name).split(" ").filter(x => x != "")
284
+ let face(s, body) = text(size: s, weight: 700, body)
285
+ let typeset(s) = [#face(s, name)#if sub != none [#linebreak()#text(size: micro, fill: mute, sub)]]
286
+ let fits(s) = words.all(x => measure(face(s, x)).width <= w) and measure(block(width: w, typeset(s))).height <= h
287
+ let s = (lead, base, small).find(fits)
288
+ if s != none { return block(width: w, height: h, align(left + horizon, typeset(s))) }
289
+ let one = measure(face(small, [A])).height
290
+ let step = measure(face(small, [A#linebreak()A])).height - one
291
+ let kept = one + calc.floor((h - one) / step) * step + 2pt
292
+ block(width: w, height: h, align(left + horizon, block(width: w, height: kept, clip: true, face(small, name))))
293
+ })
294
+
262
295
  #let span-label(span) = {
263
296
  let years = span / 2
264
297
  if calc.rem(span, 2) == 0 { [#int(years) yr] } else { [#years yr] }
@@ -297,7 +330,16 @@
297
330
  ("Year group", if board-yg != 0 [#board-yg], if adjusted [adjusted from #comm-yg]),
298
331
  ("Date of rank", show-date(data.date_of_rank), none),
299
332
  ("Arrived station", show-date(data.date_arrived_station), none),
300
- ("Advanced degree", or-none(data.advanced_degree), none),
333
+ // A degree marked "(in progress)" says so in a note under it, as the year
334
+ // group's adjustment does, rather than breaking inside the parenthesis.
335
+ {
336
+ let degree = or-none(data.advanced_degree)
337
+ let flat = plain(degree).trim()
338
+ let mark = "(in progress)"
339
+ if degree != none and flat.ends-with(mark) and flat.len() > mark.len() {
340
+ ("Advanced degree", flat.slice(0, -mark.len()).trim(), [in progress])
341
+ } else { ("Advanced degree", degree, none) }
342
+ },
301
343
  )
302
344
  #grid(
303
345
  columns: (name-width,) + (1fr,) * facts.len(),
@@ -305,7 +347,7 @@
305
347
  row-gutter: 3pt,
306
348
  align: (_, y) => if y == 0 { bottom } else { top },
307
349
  // 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)),
350
+ pad(bottom: 3pt, text(size: display, weight: 800, tracking: -0.3pt, officer)),
309
351
  ..facts.map(((name, ..)) => eyebrow(name)),
310
352
  identity,
311
353
  ..facts.map(((_, value, note)) => stack(
@@ -325,20 +367,21 @@
325
367
  // the axis
326
368
  #rows.push(table.cell[])
327
369
  #for i in range(years) {
328
- let offset = [YG+#(start-year + i - board-yg)]
370
+ let offset = yg-offset(start-year + i - board-yg)
329
371
  rows.push(table.cell(colspan: 2, stroke: (bottom: 0.7pt + ink), inset: (x: 3pt, bottom: 3pt))[
330
- #if dated [
372
+ #if not dated { text(weight: 700, offset) } else if laddered [
331
373
  #text(weight: 700)[#(start-year + i)]
332
374
  #linebreak()
333
375
  #text(size: micro, fill: mute, offset)
334
- ] else { text(weight: 700, offset) }
376
+ ] else { text(weight: 700)[#(start-year + i)] }
335
377
  ])
336
378
  }
337
379
 
338
380
  // One row per constraint, under the axis: a bar from today to the end of the
339
381
  // last year it holds, ruled under and closed at its end, so it reads as "until
340
382
  // 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.
383
+ // on past its end, on one line, over nothing; one too long for that as well is
384
+ // cut at whichever edge leaves more of it, the bar's end or the page's.
342
385
  #for (i, c) in constraints.enumerate() {
343
386
  rows.push(if i == 0 { row-label[Constraints] } else { table.cell[] })
344
387
  let note = [#text(size: micro, weight: 600)[#c.note]#text(size: micro, fill: mute)[#(" · through " + str(c.through))]]
@@ -348,9 +391,14 @@
348
391
  // A bar that runs past the window is left open at its end, since the
349
392
  // window's edge is not where it stops.
350
393
  let open = c.through > last-year
351
- let fits = measure(note).width + 8pt <= size.width
394
+ let wide = measure(note).width
395
+ let inside = size.width - 8pt
396
+ // The half-years right of the bar, less this cell's own inset.
397
+ let after = (size.width + 2pt) / c.end * (cols - c.end) - 4pt
398
+ let (dx, room) = if wide <= inside or (wide > after and inside >= after) { (3pt, inside) } else { (size.width + 3pt, after) }
352
399
  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))
400
+ // Clipped across only: the inset keeps the ascenders and descenders.
401
+ place(left + bottom, dx: dx, box(width: calc.max(0pt, room), inset: (y: 2pt), clip: true, box(width: 10in, note)))
354
402
  })
355
403
  ])
356
404
  rows += empty(cols - c.end)
@@ -383,11 +431,17 @@
383
431
  // The assignment held now, drawn once across every vector row. It reads like a
384
432
  // tour block, title over a line of small print, and sheds what its space cannot
385
433
  // hold the way one does; with nothing left, the header still names the job. The
386
- // unit is left to the header throughout.
434
+ // unit is left to the header throughout. A move past the window leaves the
435
+ // block open at the window's edge, as a tour the window cuts is.
387
436
  #let current-block = block(
388
437
  width: 100%,
389
438
  height: 100%,
390
- stroke: current-stroke,
439
+ stroke: (
440
+ left: current-stroke,
441
+ top: current-stroke,
442
+ bottom: current-stroke,
443
+ right: if move-col > cols { none } else { current-stroke },
444
+ ),
391
445
  inset: (x: 3pt, y: 2pt),
392
446
  block-text(
393
447
  if duty != none { duty } else { [Current assignment] },
@@ -405,12 +459,11 @@
405
459
  if v.track != none { sub.push(v.rank) }
406
460
  if beyond.len() > 0 { sub.push([+#beyond.len() past #window-end]) }
407
461
 
408
- rows.push(table.cell(align: left + horizon, stroke: top-rule, inset: (right: 5pt, y: 3pt))[
409
- #text(size: lead, weight: 700)[#if v.track != none { v.track } else { v.rank }]
410
- #if sub.len() > 0 [
411
- #linebreak()
412
- #text(size: micro, fill: mute)[#sub.join[ · ]]
413
- ]
462
+ rows.push(table.cell(stroke: top-rule, inset: (right: 5pt, y: 3pt))[
463
+ #block(width: 100%, height: 100%, track-label(
464
+ if v.track != none { v.track } else { [#v.rank] },
465
+ if sub.len() > 0 { sub.join[ · ] },
466
+ ))
414
467
  ])
415
468
 
416
469
  if rank == 0 and current-span > 0 {
@@ -520,7 +573,9 @@
520
573
  }
521
574
 
522
575
  // 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.
576
+ // Reviewer strat on any row, that column goes rather than printing empty. The
577
+ // rows are set at the record's size, so the table reads as one of its rows. A
578
+ // year not yet filled in arrives as 0, sorts last and prints a dash.
524
579
  #let strats = data.stratifications.sorted(key: s => -s.year)
525
580
  #let any-hlr = strats.any(s => not blank(s.hlr))
526
581
  #let strat-cell(v) = if blank(v) { dash } else { trim(v) }
@@ -531,9 +586,9 @@
531
586
  stroke: none,
532
587
  table.header([], eyebrow[Rater], ..if any-hlr { (eyebrow[HLR],) }),
533
588
  ..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)),) },
589
+ if s.year == 0 { dash } else { text(weight: 700)[#s.year] },
590
+ strat-cell(s.rater),
591
+ ..if any-hlr { (strat-cell(s.hlr),) },
537
592
  )).flatten(),
538
593
  )
539
594
 
@@ -584,26 +639,43 @@
584
639
  body
585
640
  }
586
641
 
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(
642
+ // What was typed, on at least the first rule, is the minimum and holds its
643
+ // height; the blank rules under it are whatever the page has left. The notes
644
+ // and the line they are signed on are one unit, so typed notes too long for the
645
+ // page carry the signature with them rather than leaving it alone overleaf.
646
+ //
647
+ // The section is one block sized to the rest of its page, which it reads off a
648
+ // mark left where it starts: the page cannot otherwise tell a block what is left
649
+ // of it, and the mark, being empty, stays put when the block does not fit. Notes
650
+ // too long for what is left make the block taller than that, and it goes to the
651
+ // next page whole.
652
+ #let signature = grid(
603
653
  columns: (rail, 1fr),
604
654
  rail-label[Discussed with],
605
655
  text(size: micro, fill: mute)[
606
656
  #box(width: 2.5in, stroke: (bottom: 0.4pt + mute))[]
607
657
  #h(12pt) on #box(width: 1.2in, stroke: (bottom: 0.4pt + mute))[]
608
658
  ],
609
- ))
659
+ )
660
+ #let signature-gap = 8pt
661
+ #block(height: 0pt)<notes-start>
662
+ #context {
663
+ let width = 11in - 2 * margin - rail
664
+ let notes = if type(typed) != str { on-ruling(typed) } else { [] }
665
+ let used = int(calc.round(measure(block(width: width, notes)).height / pitch))
666
+ let below = signature-gap + measure(signature).height
667
+ let room = 8.5in - margin - locate(<notes-start>).position().y
668
+ // The typed lines need only reach their last rule; the blank rules run on to
669
+ // the signature's gap.
670
+ let lines = calc.max(1, used)
671
+ let need = (lines - 1) * pitch + rule-drop + below
672
+ let rules = calc.max(lines, int(calc.floor((room - below - rule-drop) / pitch)) + 1)
673
+ block(breakable: false, width: 100%, height: calc.max(room, need), {
674
+ grid(
675
+ columns: (rail, 1fr),
676
+ on-ruling(rail-label[Notes]),
677
+ block(width: 100%, { ruling(rules); notes }),
678
+ )
679
+ place(bottom + left, signature)
680
+ })
681
+ }