@airmark/quiver 0.32.2 → 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.
- package/README.md +63 -21
- package/package.json +9 -7
- package/quills/af4141/0.1.0/Quill.yaml +4 -13
- package/quills/af4141/0.1.0/example.md +53 -0
- package/quills/afmc_moa/0.0.1/Quill.yaml +39 -62
- package/quills/afmc_moa/0.0.1/example.md +114 -0
- package/quills/afmc_moa/0.0.1/packages/typst-afmc-moa/src/config.typ +1 -1
- package/quills/classic_resume/0.0.1/Quill.yaml +281 -0
- package/quills/classic_resume/0.0.1/example.md +111 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/LICENSE +21 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +94 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +214 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +61 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +86 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +7 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +37 -0
- package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/typst.toml +26 -0
- package/quills/classic_resume/0.0.1/plate.typ +143 -0
- package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +327 -0
- package/quills/cyber_ribbon_chart/0.0.1/example.md +55 -0
- package/quills/cyber_ribbon_chart/0.0.1/plate.typ +609 -0
- package/quills/daf1206/0.1.0/Quill.yaml +15 -18
- package/quills/daf1206/0.1.0/example.md +55 -0
- package/quills/daf4392/0.1.0/Quill.yaml +16 -7
- package/quills/daf4392/0.1.0/example.md +51 -0
- package/quills/daf4392/0.1.0/plate.typ +2 -2
- package/quills/usaf_letter/0.1.0/Quill.yaml +179 -0
- package/quills/usaf_letter/0.1.0/assets/dod_seal.png +0 -0
- package/quills/usaf_letter/0.1.0/assets/dow_seal.png +0 -0
- package/quills/usaf_letter/0.1.0/example.md +35 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/.gitignore +7 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/LICENSE +201 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/LICENSE.md +79 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LICENSE +102 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
- 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
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/backmatter.typ +32 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/config.typ +45 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +226 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/lib.typ +35 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/mainmatter.typ +140 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/primitives.typ +544 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/utils.typ +334 -0
- package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/typst.toml +22 -0
- package/quills/usaf_letter/0.1.0/plate.typ +121 -0
- package/quills/usaf_memo/0.2.0/Quill.yaml +8 -41
- package/quills/usaf_memo/0.2.0/example.md +45 -0
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
- package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +1 -1
- package/quills/usaf_memo/0.3.0/Quill.yaml +51 -69
- package/quills/usaf_memo/0.3.0/example.md +48 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/OFL.txt +93 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Italic.ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/SpectralSC/SpectralSC-Regular.ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -17
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +97 -126
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +12 -45
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +81 -87
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +58 -66
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +14 -36
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +47 -19
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +158 -111
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +41 -141
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +3 -8
- package/quills/usaf_memo/0.3.0/plate.typ +78 -85
- package/templates/af4141.md +23 -0
- package/templates/appointment_letter.md +58 -0
- package/templates/daf1206.md +23 -0
- package/templates/daf4392.md +34 -0
- package/templates/deros_extension.md +40 -0
- package/templates/pass_request.md +48 -0
- package/templates/rebuttal.md +63 -0
- package/templates/templates.json +65 -0
- package/templates/usaf_template.md +30 -0
- package/templates/ussf_template.md +30 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
- package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
#import "@local/quillmark-helper:0.1.0": data, roster
|
|
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.
|
|
6
|
+
#let ink = luma(12%)
|
|
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)
|
|
31
|
+
|
|
32
|
+
#set document(
|
|
33
|
+
title: "Ribbon Chart — " + data.name,
|
|
34
|
+
author: data.name,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// A `plaintext` field lowers to content padded with a space on each side, which
|
|
38
|
+
// would print inside a bold run and defeat an emptiness test.
|
|
39
|
+
#let trim(v) = {
|
|
40
|
+
if type(v) != content { return v }
|
|
41
|
+
let kids = v.at("children", default: none)
|
|
42
|
+
if kids == none { return v }
|
|
43
|
+
let pad(c) = c == [ ] or c.func() == parbreak
|
|
44
|
+
while kids.len() > 0 and pad(kids.first()) { kids = kids.slice(1) }
|
|
45
|
+
while kids.len() > 0 and pad(kids.last()) { kids = kids.slice(0, -1) }
|
|
46
|
+
kids.sum(default: [])
|
|
47
|
+
}
|
|
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)[—]
|
|
56
|
+
|
|
57
|
+
// The bundled fallback face carries no geometric glyphs — no ✓, ■, □, ●, ▸ — so
|
|
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.
|
|
60
|
+
#let held-mark = box(baseline: 0.5pt, rect(width: 4.6pt, height: 4.6pt, fill: ink, stroke: 0.5pt + ink))
|
|
61
|
+
#let open-mark = box(baseline: 0.5pt, rect(width: 4.6pt, height: 4.6pt, fill: none, stroke: 0.5pt + mute))
|
|
62
|
+
|
|
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))
|
|
66
|
+
|
|
67
|
+
#let show-date(d) = if d == none { none } else { d.display("[day padding:none] [month repr:short] [year]") }
|
|
68
|
+
|
|
69
|
+
// ─── inputs ──────────────────────────────────────────────────────────────────
|
|
70
|
+
#let quals = data.qualifications
|
|
71
|
+
#let comm-yg = data.commissioning_yg
|
|
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
|
|
81
|
+
#let years = calc.max(1, data.timeline_years)
|
|
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.
|
|
88
|
+
#let narrow = years > 12
|
|
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}")))
|
|
149
|
+
} else {
|
|
150
|
+
plain-mark(g.name, fill: mute)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
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
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── vectors ─────────────────────────────────────────────────────────────────
|
|
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.
|
|
189
|
+
//
|
|
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
|
|
195
|
+
let out = ()
|
|
196
|
+
for t in tours {
|
|
197
|
+
out.push((tour: t, start: cursor, span: t.span))
|
|
198
|
+
cursor += t.span
|
|
199
|
+
}
|
|
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
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
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
|
+
})
|
|
261
|
+
|
|
262
|
+
#let span-label(span) = {
|
|
263
|
+
let years = span / 2
|
|
264
|
+
if calc.rem(span, 2) == 0 { [#int(years) yr] } else { [#years yr] }
|
|
265
|
+
}
|
|
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
|
+
|
|
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
|
+
)
|
|
302
|
+
#grid(
|
|
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
|
+
)),
|
|
316
|
+
)
|
|
317
|
+
|
|
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)[])
|
|
322
|
+
|
|
323
|
+
#let rows = ()
|
|
324
|
+
|
|
325
|
+
// the axis
|
|
326
|
+
#rows.push(table.cell[])
|
|
327
|
+
#for i in range(years) {
|
|
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) }
|
|
335
|
+
])
|
|
336
|
+
}
|
|
337
|
+
|
|
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
|
+
})
|
|
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))))
|
|
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
|
+
)
|
|
397
|
+
|
|
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.
|
|
401
|
+
#for (rank, v) in vectors.enumerate() {
|
|
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]) }
|
|
407
|
+
|
|
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
|
+
]
|
|
414
|
+
])
|
|
415
|
+
|
|
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
|
+
)
|
|
428
|
+
rows.push(table.cell(
|
|
429
|
+
colspan: p.drawn,
|
|
430
|
+
stroke: top-rule,
|
|
431
|
+
inset: (left: if p.cut { 0pt } else { 1pt }, right: if p.clipped { 0pt } else { 1pt }, y: 2pt),
|
|
432
|
+
)[
|
|
433
|
+
// The block is drawn inside its cell rather than as the cell's fill, so
|
|
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(
|
|
437
|
+
width: 100%,
|
|
438
|
+
height: 100%,
|
|
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
|
+
),
|
|
446
|
+
inset: (x: 3pt, y: 2pt),
|
|
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
|
+
)
|
|
453
|
+
])
|
|
454
|
+
at = p.from + p.drawn
|
|
455
|
+
}
|
|
456
|
+
rows += empty(cols - at, stroke: top-rule)
|
|
457
|
+
}
|
|
458
|
+
|
|
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(),
|
|
467
|
+
stroke: none,
|
|
468
|
+
inset: 2pt,
|
|
469
|
+
..range(years).map(i => table.vline(x: 1 + 2 * i, start: 1 + constraints.len(), stroke: 0.4pt + hair)),
|
|
470
|
+
..rows,
|
|
471
|
+
table.hline(stroke: 0.4pt + hair),
|
|
472
|
+
))
|
|
473
|
+
|
|
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
|
+
)))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ═══ RECORD ══════════════════════════════════════════════════════════════════
|
|
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.
|
|
504
|
+
#let vocabulary = (
|
|
505
|
+
("Leadership & command", roster(quals, "command")),
|
|
506
|
+
("Operations", roster(quals, "operations")),
|
|
507
|
+
("Staff & functional", roster(quals, "staff")),
|
|
508
|
+
("Education & PME", roster(quals, "education")),
|
|
509
|
+
)
|
|
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))]
|
|
517
|
+
]
|
|
518
|
+
#let members-row(rows) = {
|
|
519
|
+
(rows.filter(m => m.held) + rows.filter(m => not m.held)).map(member).join([#h(10pt) ])
|
|
520
|
+
}
|
|
521
|
+
|
|
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
|
+
)
|
|
539
|
+
|
|
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
|
+
)
|
|
550
|
+
|
|
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(),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ═══ NOTES ═══════════════════════════════════════════════════════════════════
|
|
562
|
+
// The one thing the page does that the browser tool it came from could not: a
|
|
563
|
+
// rater writes on it during the discussion, and the sheet becomes the record of
|
|
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.
|
|
580
|
+
#let typed = data.at("$body", default: "")
|
|
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
|
|
585
|
+
}
|
|
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
|
+
))
|
|
@@ -8,15 +8,11 @@ typst:
|
|
|
8
8
|
plate_file: plate.typ
|
|
9
9
|
|
|
10
10
|
main:
|
|
11
|
-
body
|
|
12
|
-
example: |
|
|
13
|
-
- First accomplishment bullet.
|
|
14
|
-
- Second accomplishment bullet.
|
|
11
|
+
description: The body holds the specific accomplishments, one bullet per line.
|
|
15
12
|
fields:
|
|
16
13
|
award:
|
|
17
14
|
type: string
|
|
18
15
|
default: ""
|
|
19
|
-
example: Outstanding Performer of the Quarter
|
|
20
16
|
category_if_applicable:
|
|
21
17
|
type: string
|
|
22
18
|
default: ""
|
|
@@ -25,43 +21,44 @@ main:
|
|
|
25
21
|
award_period:
|
|
26
22
|
type: string
|
|
27
23
|
default: ""
|
|
28
|
-
|
|
24
|
+
description: Date range, as in 1 Jan 2026 – 31 Mar 2026.
|
|
29
25
|
ui:
|
|
30
26
|
compact: true
|
|
31
27
|
rankname_of_nominee:
|
|
32
28
|
type: string
|
|
33
29
|
default: ""
|
|
34
|
-
|
|
30
|
+
description: Rank and name, as in RANK FIRST M. LAST.
|
|
35
31
|
ui:
|
|
36
32
|
compact: true
|
|
37
33
|
majcom_fldcom_foa_or_dru:
|
|
38
34
|
type: string
|
|
39
35
|
default: ""
|
|
40
|
-
|
|
36
|
+
description: Abbreviation, as in ACC.
|
|
41
37
|
ui:
|
|
42
38
|
compact: true
|
|
43
39
|
dafsc_duty_title:
|
|
44
40
|
type: string
|
|
45
41
|
default: ""
|
|
46
|
-
|
|
42
|
+
description: DAFSC, then duty title.
|
|
47
43
|
nominees_telephone_dsn_commercial:
|
|
48
44
|
type: string
|
|
49
45
|
default: ""
|
|
50
|
-
|
|
46
|
+
description: DSN and commercial numbers, as in DSN XXX-XXXX / (XXX) XXX-XXXX.
|
|
51
47
|
unit_office_symbol_street_address_base_state_zip:
|
|
52
48
|
type: string
|
|
53
49
|
default: ""
|
|
54
|
-
|
|
50
|
+
description: One line, as in UNIT/SYMBOL, STREET, BASE ST ZIP.
|
|
55
51
|
unit_commander_rank_name_and_telephone:
|
|
56
52
|
type: string
|
|
57
53
|
default: ""
|
|
58
|
-
|
|
54
|
+
description: Rank, name and telephone, as in RANK FIRST M. LAST, (XXX) XXX-XXXX.
|
|
59
55
|
|
|
60
56
|
card_kinds:
|
|
61
57
|
accomplishments_continued:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
title: Specific Accomplishments (Continued — Page 2)
|
|
59
|
+
description: Optional. Add this card only when page 2 overflow is required. Use the card body for continued bullets, one per line. Rank/name on page 2 matches the main nominee line automatically.
|
|
60
|
+
seed:
|
|
61
|
+
$body: |
|
|
62
|
+
**Category (continued)**
|
|
63
|
+
|
|
64
|
+
- Continued accomplishment bullet
|