@airmark/quiver 0.32.2 → 0.33.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 (76) hide show
  1. package/README.md +54 -18
  2. package/package.json +8 -6
  3. package/quills/af4141/0.1.0/Quill.yaml +1 -1
  4. package/quills/afmc_moa/0.0.1/Quill.yaml +10 -17
  5. package/quills/classic_resume/0.0.1/Quill.yaml +361 -0
  6. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/LICENSE +21 -0
  7. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Bold.ttf +0 -0
  8. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-BoldItalic.ttf +0 -0
  9. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Italic.ttf +0 -0
  10. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/EBGaramond-Regular.ttf +0 -0
  11. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/fonts/EBGaramond/OFL.txt +94 -0
  12. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/components.typ +181 -0
  13. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/config.typ +53 -0
  14. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/layout.typ +57 -0
  15. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/lib.typ +6 -0
  16. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/src/util.typ +32 -0
  17. package/quills/classic_resume/0.0.1/packages/ttq-classic-resume/typst.toml +26 -0
  18. package/quills/classic_resume/0.0.1/plate.typ +143 -0
  19. package/quills/cyber_ribbon_chart/0.0.1/Quill.yaml +320 -0
  20. package/quills/cyber_ribbon_chart/0.0.1/plate.typ +428 -0
  21. package/quills/daf1206/0.1.0/Quill.yaml +1 -2
  22. package/quills/daf4392/0.1.0/Quill.yaml +15 -1
  23. package/quills/daf4392/0.1.0/plate.typ +2 -2
  24. package/quills/usaf_letter/0.1.0/Quill.yaml +214 -0
  25. package/quills/usaf_letter/0.1.0/assets/dod_seal.png +0 -0
  26. package/quills/usaf_letter/0.1.0/assets/dow_seal.png +0 -0
  27. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/.gitignore +7 -0
  28. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/LICENSE +201 -0
  29. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  30. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/Cinzel/LICENSE +93 -0
  31. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Bold.otf +0 -0
  32. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/CopperplateCC-Heavy.otf +0 -0
  33. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/CopperplateCC/LICENSE.md +79 -0
  34. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LICENSE +102 -0
  35. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/LiberationMono/LiberationMono-Regular.ttf +0 -0
  36. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/GNU General Public License.txt +340 -0
  37. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Med.otf +0 -0
  38. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-MedIta.otf +0 -0
  39. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-Reg.otf +0 -0
  40. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/fonts/NimbusRomanNo9L/NimbusRomNo9L-RegIta.otf +0 -0
  41. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/backmatter.typ +32 -0
  42. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/config.typ +45 -0
  43. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/frontmatter.typ +226 -0
  44. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/lib.typ +35 -0
  45. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/mainmatter.typ +140 -0
  46. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/primitives.typ +544 -0
  47. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/src/utils.typ +334 -0
  48. package/quills/usaf_letter/0.1.0/packages/tonguetoquill-usaf-letter/typst.toml +22 -0
  49. package/quills/usaf_letter/0.1.0/plate.typ +124 -0
  50. package/quills/usaf_memo/0.2.0/Quill.yaml +1 -1
  51. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  52. package/quills/usaf_memo/0.2.0/packages/tonguetoquill-usaf-memo/typst.toml +1 -1
  53. package/quills/usaf_memo/0.3.0/Quill.yaml +62 -43
  54. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/LICENSE +201 -21
  55. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel-Regular.ttf +0 -0
  56. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/backmatter.typ +29 -17
  57. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/body.typ +97 -126
  58. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/config.typ +12 -45
  59. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/frontmatter.typ +81 -87
  60. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/indorsement.typ +58 -66
  61. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/lib.typ +14 -36
  62. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/mainmatter.typ +47 -19
  63. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/primitives.typ +158 -111
  64. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/src/utils.typ +41 -141
  65. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/typst.toml +3 -8
  66. package/quills/usaf_memo/0.3.0/plate.typ +81 -85
  67. package/templates/af4141.md +23 -0
  68. package/templates/appointment_letter.md +58 -0
  69. package/templates/daf1206.md +23 -0
  70. package/templates/daf4392.md +34 -0
  71. package/templates/pass_request.md +48 -0
  72. package/templates/rebuttal.md +63 -0
  73. package/templates/templates.json +58 -0
  74. package/templates/usaf_template.md +30 -0
  75. package/templates/ussf_template.md +30 -0
  76. package/quills/usaf_memo/0.3.0/packages/tonguetoquill-usaf-memo/fonts/Cinzel/Cinzel[wght].ttf +0 -0
@@ -0,0 +1,428 @@
1
+ #import "@local/quillmark-helper:0.1.0": data
2
+
3
+ #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)
9
+
10
+ #set document(
11
+ title: "Ribbon Chart — " + data.name,
12
+ author: data.name,
13
+ )
14
+
15
+ // A `plaintext` field lowers to content padded with a space on each side, which
16
+ // would print inside a bold run and defeat an emptiness test.
17
+ #let trim(v) = {
18
+ if type(v) != content { return v }
19
+ let kids = v.at("children", default: none)
20
+ if kids == none { return v }
21
+ let pad(c) = c == [ ] or c.func() == parbreak
22
+ while kids.len() > 0 and pad(kids.first()) { kids = kids.slice(1) }
23
+ while kids.len() > 0 and pad(kids.last()) { kids = kids.slice(0, -1) }
24
+ kids.sum(default: [])
25
+ }
26
+ #let blank(v) = { let t = trim(v); t == [] or t == "" }
27
+
28
+ // 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.
31
+ #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
+ ]
39
+
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
+ ]
45
+
46
+ #let show-date(d) = if d == none { none } else { d.display("[day padding:none] [month repr:short] [year]") }
47
+
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
+ // ─── inputs ──────────────────────────────────────────────────────────────────
70
+ #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
74
+ #let years = calc.max(1, data.timeline_years)
75
+ #let cols = years * 2
76
+ #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]]
88
+ } else {
89
+ box(inset: (x: 0.5pt, y: 2pt))[#text(size: chip-size, fill: mute)[#label]]
90
+ }
91
+ }
92
+
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
+
98
+
99
+ // ─── 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.
117
+ //
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
122
+ let out = ()
123
+ 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
140
+ }
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)
143
+ })
144
+ }
145
+
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 }
150
+
151
+ // Half-columns back to years, so a stretched block prints the length it draws
152
+ // rather than the length it was given.
153
+ #let span-label(span) = {
154
+ let years = span / 2
155
+ if calc.rem(span, 2) == 0 { [#int(years) yr] } else { [#years yr] }
156
+ }
157
+
158
+ // ═══ IDENTITY ════════════════════════════════════════════════════════════════
159
+ #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
+ ),
180
+ )
181
+
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)
202
+
203
+ #let rows = ()
204
+
205
+ // year header
206
+ #rows.push(table.cell(stroke: none)[])
207
+ #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)]
212
+ ])
213
+ }
214
+
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
+ ]
226
+ ])
227
+ }
228
+
229
+ // one row per vector
230
+ #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))
235
+
236
+ 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 [
239
+ #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
+ ]
247
+ ]
248
+ ])
249
+
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
+ }
257
+ rows.push(table.cell(
258
+ colspan: p.drawn,
259
+ stroke: top-rule,
260
+ inset: (x: 1pt, y: 2pt),
261
+ align: left + horizon,
262
+ )[
263
+ // 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(
267
+ width: 100%,
268
+ 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%) },
271
+ 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
+ ]
283
+ ])
284
+ at = p.start + p.drawn
285
+ idx += 1
286
+ }
287
+ if at < cols { rows.push(table.cell(colspan: cols - at, stroke: top-rule)[]) }
288
+ }
289
+
290
+ #table(
291
+ columns: (1.2in,) + (1fr,) * cols,
292
+ rows: (auto, auto) + (band-height,) * vectors.len(),
293
+ stroke: none,
294
+ inset: 2pt,
295
+ ..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]) }
311
+
312
+ #if legend.len() > 0 {
313
+ v(3pt)
314
+ text(size: 5.8pt, fill: mute, legend.join(h(6pt)))
315
+ }
316
+
317
+ #v(9pt)
318
+
319
+ // ═══ 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
323
+ #let vocabulary = (
324
+ ("Leadership & Command", quals.command),
325
+ ("Operations", quals.operations),
326
+ ("Staff & Functional", quals.staff),
327
+ ("Education & PME", quals.education),
328
+ )
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
+ }
352
+ ]
353
+
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
+ ]
386
+
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
+ }
392
+
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
+ ]),
406
+ )
407
+ ]
408
+
409
+ // ═══ NOTES ═══════════════════════════════════════════════════════════════════
410
+ // The one thing the page does that the browser tool it came from could not: a
411
+ // 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")
416
+ #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%))
423
+ }
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
+ ]
@@ -59,9 +59,8 @@ main:
59
59
 
60
60
  card_kinds:
61
61
  accomplishments_continued:
62
+ title: Specific Accomplishments (Continued — Page 2)
62
63
  description: Optional. Add this card only when page 2 overflow is required. Use the card body for continued bullets. Rank/name on page 2 matches the main nominee line automatically.
63
- ui:
64
- title: Specific Accomplishments (Continued — Page 2)
65
64
  body:
66
65
  example: |
67
66
  - Continued accomplishment bullet.
@@ -83,6 +83,20 @@ main:
83
83
  compact: true
84
84
  description: "Put in your Grade."
85
85
 
86
+ briefer_name:
87
+ type: string
88
+ default: ""
89
+ ui:
90
+ compact: true
91
+ description: "Name of the person giving the briefing (Last, First)."
92
+
93
+ briefer_grade:
94
+ type: string
95
+ default: ""
96
+ ui:
97
+ compact: true
98
+ description: "Grade of the person giving the briefing."
99
+
86
100
  emergency_contact_name:
87
101
  type: string
88
102
  default: ""
@@ -95,9 +109,9 @@ main:
95
109
 
96
110
  card_kinds:
97
111
  itinerary:
112
+ title: Proposed Travel Itinerary
98
113
  description: "Each card represents one row in the travel itinerary table. Up to 10 rows are supported."
99
114
  ui:
100
- title: Proposed Travel Itinerary
101
115
  groups: [itinerary_entry]
102
116
  body:
103
117
  enabled: false
@@ -47,8 +47,8 @@
47
47
  #if data.briefed_date != none { vals.insert("briefed_date", data.briefed_date) }
48
48
  #if data.briefee_name != "" { vals.insert("briefee_name", data.briefee_name) }
49
49
  #if data.briefee_grade != "" { vals.insert("briefee_grade", data.briefee_grade) }
50
- #if "briefer_name" in data { vals.insert("briefer_name", data.briefer_name) }
51
- #if "briefer_grade" in data { vals.insert("briefer_grade", data.briefer_grade) }
50
+ #if data.briefer_name != "" { vals.insert("briefer_name", data.briefer_name) }
51
+ #if data.briefer_grade != "" { vals.insert("briefer_grade", data.briefer_grade) }
52
52
 
53
53
  // Emergency contact — bold label prefixes a "name: phone" pair.
54
54
  #{
@@ -0,0 +1,214 @@
1
+ quill:
2
+ name: usaf_letter
3
+ version: 0.1.0
4
+ backend: typst
5
+ description: U.S. Air Force and Space Force Personal Letter
6
+
7
+ typst:
8
+ plate_file: plate.typ
9
+
10
+ main:
11
+ ui:
12
+ groups: [addressing, letterhead, classification, additional]
13
+ body:
14
+ example: |
15
+ Thank you for asking about the personal letter. The personal letter conveys official information in a personal manner to a limited number of people, which is what makes it the right form for a reply to an inquiry, a welcome, a note of appreciation, or a letter of condolence.
16
+
17
+ Paragraphs are indented and take no number or letter. Leave one blank line between them.
18
+
19
+ Lines that stand as written — a roster of names, an address — go in a block quote, typeset flush with the margin. End a line with a backslash to break it.
20
+
21
+ > MARY E. BROWN, Maj, USAF\
22
+ > ROBERT L. JONES, Capt, USAF
23
+
24
+ The last paragraph may carry your contact information: DSN and commercial telephone numbers and your official email address.
25
+ fields:
26
+ letter_from:
27
+ type: array
28
+ items:
29
+ type: plaintext
30
+ inline: true
31
+ example:
32
+ - Colonel Jane A. Doe
33
+ - Commander
34
+ - 123d Example Squadron
35
+ - 1 Example Way
36
+ - Example AFB ST 12345-6789
37
+ ui:
38
+ group: addressing
39
+ compact: true
40
+ description: "Your full rank and full name, then your duty title and mailing address, one line each. Printed on the second line below the date. Do not put a comma between the city and the state abbreviation."
41
+
42
+ letter_for:
43
+ type: array
44
+ items:
45
+ type: plaintext
46
+ inline: true
47
+ example:
48
+ - Major John B. Smith
49
+ - Chief, Plans and Programs
50
+ - 456th Example Group
51
+ - 2 Sample Boulevard
52
+ - Sample AFB ST 98765-4321
53
+ ui:
54
+ group: addressing
55
+ compact: true
56
+ description: "The receiver's full rank and full name, then their duty title and mailing address, one line each. Printed on the third line below your own address. Do not put a comma between the city and the state abbreviation."
57
+
58
+ salutation:
59
+ type: plaintext
60
+ inline: true
61
+ example: Dear Major Smith
62
+ ui:
63
+ group: addressing
64
+ compact: true
65
+ description: "The form of address for the receiver's rank followed by their last name — see 'Forms of Address, Salutation, and Complimentary Close' in AFH 33-337. Take no punctuation after the last name."
66
+
67
+ date:
68
+ type: date
69
+ default: ""
70
+ ui:
71
+ group: addressing
72
+ compact: true
73
+ description: "YYYY-MM-DD, printed as '15 September 2026' flush with the right margin. Leave blank to print a fillable PDF text field for the signer to type the date into when signing."
74
+
75
+ complimentary_close:
76
+ type: plaintext
77
+ inline: true
78
+ default: Sincerely
79
+ ui:
80
+ group: addressing
81
+ compact: true
82
+ description: "Printed on the second line below the last line of text, above the signature block. AFH 33-337's 'Forms of Address, Salutation, and Complimentary Close' tables give the close each receiver takes; 'Sincerely' is the usual one."
83
+
84
+ signature_block:
85
+ type: array
86
+ items:
87
+ type: plaintext
88
+ inline: true
89
+ example:
90
+ - "JANE A. DOE, Col, USAF"
91
+ ui:
92
+ group: addressing
93
+ compact: true
94
+ description: "Name in UPPERCASE as signed, grade, and service, printed on the fifth line below the complimentary close. A personal letter does not carry the sender's duty title, which the heading has already given."
95
+
96
+ letterhead_title:
97
+ type: array
98
+ items:
99
+ type: plaintext
100
+ inline: true
101
+ default:
102
+ - DEPARTMENT OF THE AIR FORCE
103
+ example:
104
+ - DEPARTMENT OF THE AIR FORCE
105
+ - 123D EXAMPLE SQUADRON
106
+ ui:
107
+ group: letterhead
108
+ description: "Letterhead lines, top to bottom, all rendered in uppercase. The first is the department title, set larger than the rest. Change that line only for a Joint Command or a DoW agency. The lines below it are your unit's full organization name."
109
+
110
+ letterhead_seal:
111
+ type: enum
112
+ values:
113
+ - dow
114
+ - dod
115
+ default: dow
116
+ ui:
117
+ group: letterhead
118
+ compact: true
119
+ description: "Department of War (DoW) or Department of Defense (DoD) seal, shown at the top left of the letterhead."
120
+
121
+ letterhead_seal_subtitle:
122
+ type: plaintext
123
+ inline: true
124
+ default: ""
125
+ ui:
126
+ group: letterhead
127
+ compact: true
128
+ description: "Bold-caps line below the seal. Nothing prints when it is blank."
129
+
130
+ tag_line:
131
+ type: richtext
132
+ inline: true
133
+ default: ""
134
+ ui:
135
+ group: letterhead
136
+ compact: true
137
+ description: "Organizational motto, centered in the footer of every page. Supports Markdown emphasis: *italics*, **bold**."
138
+
139
+ classification:
140
+ type: enum
141
+ values:
142
+ - UNCLASSIFIED
143
+ - CUI
144
+ - CONFIDENTIAL
145
+ - SECRET
146
+ - TOP SECRET
147
+ default: ""
148
+ ui:
149
+ group: classification
150
+ compact: true
151
+ description: "Classification marking, banner-printed at the top and bottom of every page. Leave blank for no banner, which is what an unclassified document normally carries. Mark per DAFI 16-1404, DoDM 5200.48 for CUI, and applicable DoD guidance."
152
+ variants:
153
+ CUI:
154
+ controlled_by:
155
+ type: plaintext
156
+ inline: true
157
+ example: SAF/AA
158
+ description: "Office or organization that designated this information as CUI. Required by DoDM 5200.48."
159
+ poc:
160
+ type: plaintext
161
+ inline: true
162
+ example: "Capt J. Smith, DSN 555-1234, john.smith@us.af.mil"
163
+ description: "Point of contact for CUI questions. Required by DoDM 5200.48."
164
+ category:
165
+ type: plaintext
166
+ inline: true
167
+ default: ""
168
+ description: "CUI category from the DoD CUI Registry, such as 'Privacy/MED', 'CTI', or 'PRVCY'. Leave blank to omit."
169
+ limited_dissemination:
170
+ type: plaintext
171
+ inline: true
172
+ default: ""
173
+ description: "Limited dissemination control shown in the indicator block, such as 'FEDONLY' or 'NOFORN'; rendered in uppercase. The banner's own suffix is the separate Dissemination field."
174
+
175
+ dissemination:
176
+ type: plaintext
177
+ inline: true
178
+ default: ""
179
+ ui:
180
+ group: classification
181
+ compact: true
182
+ description: "Suffix appended to the banner after a double slash, rendered in uppercase: 'NF' prints as CUI//NF. Leave blank for no suffix."
183
+
184
+ cc:
185
+ type: array
186
+ items:
187
+ type: plaintext
188
+ inline: true
189
+ default: []
190
+ example:
191
+ - 456 EXG/CC
192
+ ui:
193
+ group: additional
194
+ description: Offices or people receiving a courtesy copy, one per line.
195
+
196
+ attachments:
197
+ type: array
198
+ items:
199
+ type: richtext
200
+ inline: true
201
+ default: []
202
+ example:
203
+ - Squadron Newcomer Guide, 1 September 2026
204
+ ui:
205
+ group: additional
206
+ description: "Attachments in the order the letter mentions them. Describe each briefly; do not write 'as stated' or abbreviate. A lone attachment prints unnumbered, two or more are numbered. Italicize publication titles with *asterisks*."
207
+
208
+ font_size:
209
+ type: number
210
+ default: 12
211
+ ui:
212
+ group: additional
213
+ compact: true
214
+ description: "Body text size in points; decimals allowed, such as 11.5. AFH 33-337 specifies 12."