@airmark/quiver 0.31.0 → 0.32.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "Airmark Quiver — Air Force and DAF document Quills for Quillmark.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,9 +16,9 @@
16
16
  "node": ">=24"
17
17
  },
18
18
  "devDependencies": {
19
- "@quillmark/quiver": "0.23.0",
20
- "@quillmark/wasm": "^0.105.0",
21
- "quillkit": "0.3.0"
19
+ "@quillmark/quiver": "0.26.0",
20
+ "@quillmark/wasm": "^0.109.1",
21
+ "quillkit": "0.5.3"
22
22
  },
23
23
  "scripts": {
24
24
  "test": "quillkit test",
@@ -3,23 +3,15 @@
3
3
 
4
4
  #set text(font: "NimbusRomNo9L")
5
5
 
6
- // A `type: date` field lowers to a click-to-edit value object
7
- // `(value: datetime, display: closure)`; `display` is a stored closure on a dict
8
- // rather than a method, so it is called through parentheses, and the content it
9
- // returns carries the region that makes the glyphs clickable. A native datetime
10
- // is still accepted. Missing / blank dates arrive as `none`; empty strings may
11
- // also appear from input.
6
+ // A `type: date` field arrives as a native `datetime`, blank as `none`. The
7
+ // form's cells are strings, so a date is formatted here rather than placed as
8
+ // ink: `display` would give the glyphs a schema address, but the form draws
9
+ // each cell into a fixed box it builds itself, which takes a `str`.
12
10
  #let form-cell(v) = {
13
11
  let pattern = "[month padding:none]/[day padding:none]/[year]"
14
12
  if v == none { "" }
15
- else if type(v) == str and v == "" { "" }
16
- else if type(v) == dictionary {
17
- (v.display)(pattern)
18
- } else if type(v) == datetime {
19
- v.display(pattern)
20
- } else {
21
- str(v)
22
- }
13
+ else if type(v) == datetime { v.display(pattern) }
14
+ else { str(v) }
23
15
  }
24
16
 
25
17
  // Column order within each 7-field row group
@@ -38,4 +38,4 @@
38
38
  #import "mainmatter.typ": mainmatter
39
39
  #import "attachment.typ": render-attachment-a as attachment, render-attachment-card as attachment-card
40
40
  #import "backmatter.typ": backmatter
41
- #import "utils.typ": trim-inline
41
+ #import "utils.typ": date-pattern, trim-inline
@@ -82,23 +82,22 @@
82
82
  if joined == none { [] } else { joined }
83
83
  }
84
84
 
85
+ /// The date pattern this package prints ("22 July 2026"). Exported so a caller
86
+ /// that pre-formats a date matches it instead of restating it.
87
+ #let date-pattern = "[day padding:none] [month repr:long] [year]"
88
+
85
89
  /// Format a date value for inline display ("22 July 2026").
86
90
  ///
87
- /// Dispatches on the shape a date field can arrive in:
88
- /// - str: shown as-is (fixed text like a placeholder).
89
- /// - datetime: a native Typst datetime (e.g. `datetime.today()`).
90
- /// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
91
- /// object `{ value: datetime, display: closure }`; `display` is a stored
92
- /// closure, so it must be called through parentheses — a dict has no
93
- /// `.display` method.
91
+ /// - str, content: shown as-is (a fixed placeholder, or ink the caller already
92
+ /// formatted through [`date-pattern`]).
93
+ /// - datetime: formatted to [`date-pattern`].
94
94
  #let format-date(d) = {
95
95
  if d == none {
96
96
  ""
97
- } else if type(d) == str {
97
+ } else if type(d) in (str, content) {
98
98
  d
99
99
  } else {
100
- let pattern = "[day padding:none] [month repr:long] [year]"
101
- if type(d) == datetime { d.display(pattern) } else { (d.display)(pattern) }
100
+ d.display(date-pattern)
102
101
  }
103
102
  }
104
103
 
@@ -1,6 +1,6 @@
1
- #import "@local/quillmark-helper:0.1.0": data, signature-field
1
+ #import "@local/quillmark-helper:0.1.0": data, display, signature-field
2
2
  #import "@local/typst-afmc-moa:0.1.0": (
3
- attachment, attachment-card, backmatter, frontmatter, mainmatter, trim-inline,
3
+ attachment, attachment-card, backmatter, date-pattern, frontmatter, mainmatter, trim-inline,
4
4
  )
5
5
 
6
6
  // Every text field is `plaintext`/`richtext`, so it arrives as content carrying
@@ -34,7 +34,12 @@
34
34
 
35
35
  first_party_signatory: text-field("first_party_signatory"),
36
36
  second_party_signatory: text-field("second_party_signatory"),
37
- mid_point_review_due_date: data.mid_point_review_due_date,
37
+ // `data.mid_point_review_due_date` is the native `datetime` and would render
38
+ // identically, but its ink would be born inside the package and carry no
39
+ // schema address. `display` places the field's content projection instead, so
40
+ // the printed date stays click-to-edit; a blank one yields `none`, which
41
+ // `format-date` renders as nothing.
42
+ mid_point_review_due_date: display("mid_point_review_due_date", date-pattern),
38
43
  )
39
44
 
40
45
  // ── Frontmatter: page setup, title block, intro paragraph ──────────────────
@@ -15,6 +15,11 @@ main:
15
15
  The first paragraph. Top-level paragraphs are auto-numbered; do not add manual numbering.
16
16
 
17
17
  - Nested bullets are automatically lettered.
18
+
19
+ Lines that take no number, letter, or bullet — a roster of names, an address — go in a block quote, typeset as written. End a line with a backslash to break it.
20
+
21
+ > FIRST M. LAST, Maj, USAF\
22
+ > SECOND N. LAST, Capt, USAF
18
23
  fields:
19
24
  memo_for:
20
25
  type: array
@@ -46,12 +51,12 @@ main:
46
51
  description: "If recipients are on the same installation, use only the office symbol. For recipients on other installations, include the full mailing address to enable return correspondence. Omit entirely to produce a Memorandum for Record (no MEMO FROM line)."
47
52
 
48
53
  subject:
49
- type: plaintext
54
+ type: richtext
50
55
  inline: true
51
56
  example: Subject of the Memorandum
52
57
  ui:
53
58
  group: addressing
54
- description: Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable.
59
+ description: "Be brief and clear. Capitalize the first letter of each word except articles, prepositions, and conjunctions. Include suspense dates in parentheses if applicable. Italicize publication titles with *asterisks* (standard Markdown emphasis), as in the References field."
55
60
 
56
61
  signature_block:
57
62
  type: array
@@ -66,6 +71,14 @@ main:
66
71
  compact: true
67
72
  description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
68
73
 
74
+ date:
75
+ type: date
76
+ default: ""
77
+ ui:
78
+ group: addressing
79
+ compact: true
80
+ description: YYYY-MM-DD. Leave blank to use today's date.
81
+
69
82
  letterhead_title:
70
83
  type: array
71
84
  items:
@@ -122,6 +135,28 @@ main:
122
135
  group: classification
123
136
  compact: true
124
137
  description: "Select the classification marking shown in the header and footer banner. Unclassified documents typically omit this banner entirely — leave blank unless the document is CUI or classified. Mark per DAFI 16-1404 (DoDM 5200.48 for CUI) and applicable DoD guidance."
138
+ variants:
139
+ CUI:
140
+ controlled_by:
141
+ type: plaintext
142
+ inline: true
143
+ example: SAF/AA
144
+ description: "Office or organization that designated this information as CUI. Required by DoDM 5200.48."
145
+ poc:
146
+ type: plaintext
147
+ inline: true
148
+ example: "Capt J. Smith, DSN 555-1234, john.smith@us.af.mil"
149
+ description: "Point of contact for CUI questions. Required by DoDM 5200.48."
150
+ category:
151
+ type: plaintext
152
+ inline: true
153
+ default: ""
154
+ description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
155
+ limited_dissemination:
156
+ type: plaintext
157
+ inline: true
158
+ default: ""
159
+ description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix."
125
160
 
126
161
  dissemination:
127
162
  type: plaintext
@@ -132,42 +167,6 @@ main:
132
167
  compact: true
133
168
  description: "Banner dissemination suffix appended after double slash (e.g. 'NF' renders as CUI//NF in the header/footer). Leave blank for no suffix."
134
169
 
135
- cui_controlled_by:
136
- type: plaintext
137
- inline: true
138
- default: ""
139
- ui:
140
- group: classification
141
- compact: true
142
- description: "Office or organization that designated this information as CUI (e.g., 'SAF/AA'). Required by DoDM 5200.48 when classification is CUI."
143
-
144
- cui_category:
145
- type: plaintext
146
- inline: true
147
- default: ""
148
- ui:
149
- group: classification
150
- compact: true
151
- description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
152
-
153
- cui_limited_dissemination:
154
- type: plaintext
155
- inline: true
156
- default: ""
157
- ui:
158
- group: classification
159
- compact: true
160
- description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix above."
161
-
162
- cui_poc:
163
- type: plaintext
164
- inline: true
165
- default: ""
166
- ui:
167
- group: classification
168
- compact: true
169
- description: "Point of contact for CUI questions (e.g., 'Capt J. Smith, DSN 555-1234, john.smith@us.af.mil'). Required by DoDM 5200.48 when classification is CUI."
170
-
171
170
  references:
172
171
  type: array
173
172
  items:
@@ -209,14 +208,14 @@ main:
209
208
  attachments:
210
209
  type: array
211
210
  items:
212
- type: plaintext
211
+ type: richtext
213
212
  inline: true
214
213
  default: []
215
214
  example:
216
215
  - Attachment description, YYYY MMM DD
217
216
  ui:
218
217
  group: additional
219
- description: List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations.
218
+ description: "List attachments in the order they are mentioned in the memo. Briefly describe each; do not use 'as stated' or abbreviations. Italicize publication titles with *asterisks* (standard Markdown emphasis), as in the References field."
220
219
 
221
220
  memo_style:
222
221
  type: enum
@@ -237,14 +236,6 @@ main:
237
236
  compact: true
238
237
  description: Body text size in points; decimals allowed (e.g. 11.5).
239
238
 
240
- date:
241
- type: date
242
- default: ""
243
- ui:
244
- group: additional
245
- compact: true
246
- description: YYYY-MM-DD. Leave blank to use today's date.
247
-
248
239
  card_kinds:
249
240
  indorsement:
250
241
  description: Chain of routing endorsements. Each endorsement block adds an official response or forwarding action to the original memo.
@@ -280,6 +271,13 @@ card_kinds:
280
271
  group: addressing
281
272
  compact: true
282
273
  description: "Line 1: Name in UPPERCASE as signed, grade, and service. Line 2: Duty title."
274
+ date:
275
+ type: date
276
+ default: ""
277
+ ui:
278
+ group: addressing
279
+ compact: true
280
+ description: "YYYY-MM-DD. Date the endorser signs/forwards the memo (per AFH 33-337 Ch. 14, this is distinct from the originating memo's date). Leave blank to render a fillable PDF text field for the endorser to type the date into when signing."
283
281
  format:
284
282
  type: enum
285
283
  values:
@@ -297,18 +295,8 @@ card_kinds:
297
295
  - undecided
298
296
  - approve
299
297
  - disapprove
300
- - undecided_concur
301
- - concur
302
- - nonconcur
303
- default: ""
304
- ui:
305
- group: additional
306
- compact: true
307
- description: "Decision taken by the endorser, shown as an either/or line with the choice circled. Leave blank to hide the line entirely. Approval authority: 'approve' or 'disapprove' (use 'undecided' to print Approve/Disapprove for the endorser to circle by hand). Coordinating official: 'concur' or 'nonconcur' (use 'undecided_concur' to print Concur/Nonconcur unmarked)."
308
- date:
309
- type: date
310
298
  default: ""
311
299
  ui:
312
300
  group: additional
313
301
  compact: true
314
- description: "YYYY-MM-DD. Date the endorser signs/forwards the memo (per AFH 33-337 Ch. 14, this is distinct from the originating memo's date). Leave blank to render a fillable PDF text field for the endorser to type the date into when signing."
302
+ description: "Decision taken by the endorser, shown as an either/or line with the choice underlined and the rejected option struck out. Leave blank to hide the line entirely. The wording is automatic: the last indorsement is the approval authority and reads Approve/Disapprove, while every earlier one is a coordinating official and reads Concur/Nonconcur. Use 'undecided' to print the pair unmarked for the endorser to mark by hand."
@@ -60,6 +60,25 @@
60
60
  level-counts
61
61
  }
62
62
 
63
+ /// The horizontal offset at which a paragraph's *text* sits: its indent plus
64
+ /// the width of the number label that precedes it.
65
+ ///
66
+ /// This is where a later block of the same paragraph — a continuation, a block
67
+ /// quote — lines up, so the two callers share one measurement rather than each
68
+ /// spelling it. A top-level paragraph carries no indent and its label is the
69
+ /// left margin itself, so the offset is zero.
70
+ ///
71
+ /// - level (int): Nesting level (0-based)
72
+ /// - level-counts (dictionary): Current counter values per level
73
+ /// - indent-fn (function): `(level, level-counts) -> length`
74
+ /// -> length
75
+ #let paragraph-text-offset(level, level-counts, indent-fn) = {
76
+ if level <= 0 { return 0pt }
77
+ let current-value = level-counts.at(str(level), default: 1)
78
+ let number-text = numbering(get-paragraph-numbering-format(level), current-value)
79
+ indent-fn(level, level-counts) + measure([#number-text#" "]).width
80
+ }
81
+
63
82
  /// Formats a paragraph (or continuation) with a given indent strategy.
64
83
  ///
65
84
  /// - body (content): Paragraph content
@@ -69,12 +88,13 @@
69
88
  /// - continuation (bool): If true, adds number-label width to alignment
70
89
  /// -> content
71
90
  #let format-par(body, level, level-counts, indent-fn, continuation: false) = {
72
- let indent-width = indent-fn(level, level-counts)
73
91
  if continuation {
74
- let current-value = level-counts.at(str(level), default: 1)
75
- let number-text = numbering(get-paragraph-numbering-format(level), current-value)
76
- [#h(indent-width + measure([#number-text#" "]).width)#body]
92
+ // Offset the first line only, which is how the numbered paragraph this
93
+ // block continues is set: its own wrapped lines return to the left margin
94
+ // too, so the two read as one paragraph.
95
+ [#h(paragraph-text-offset(level, level-counts, indent-fn))#body]
77
96
  } else {
97
+ let indent-width = indent-fn(level, level-counts)
78
98
  let current-value = level-counts.at(str(level), default: 1)
79
99
  let number-text = numbering(get-paragraph-numbering-format(level), current-value)
80
100
  [#h(indent-width)#number-text#" "#body]
@@ -145,6 +165,38 @@
145
165
  })
146
166
  t
147
167
  }
168
+ // Collect block quotes — captured as-is, with paragraph processing kept
169
+ // out of them. AFH 33-337 numbers paragraphs and letters subparagraphs, and
170
+ // a body sometimes has to hold lines that are neither: a roster of names, a
171
+ // quoted passage, an address. The block quote is where an author says so —
172
+ // its content is emitted verbatim, so nothing inside it takes a number, a
173
+ // letter, or a bullet.
174
+ //
175
+ // Returning `none` rather than the quote is what makes that true: the
176
+ // quote's own paragraphs are never laid out here, so the `show par` above
177
+ // never sees them and cannot number them. The buffered body is emitted in
178
+ // the second pass, where these show rules are out of scope. Typst's own
179
+ // block-quote framing (padding, attribution) is dropped with the element;
180
+ // what reaches the page is the author's lines and nothing else.
181
+ //
182
+ // The nesting level rides along so a quote inside a list item lines up with
183
+ // that item's text (see the emission below). @quillmark/wasm 0.109.0 is
184
+ // what makes that level trustworthy: before it, a container inside a list
185
+ // item opened at column 0 and terminated the list, so a quote's siblings
186
+ // came back as top-level paragraphs (#123). It now nests, and this quill
187
+ // stopped declining the construct with it.
188
+ show quote.where(block: true): q => context {
189
+ let nest_level = NEST_DOWN.get().at(0) - NEST_UP.get().at(0)
190
+ PAR_BUFFER.update(pars => {
191
+ pars.push((
192
+ content: q.body,
193
+ nest_level: nest_level,
194
+ kind: "quote",
195
+ ))
196
+ pars
197
+ })
198
+ none
199
+ }
148
200
  {
149
201
  show heading: h => {
150
202
  IS_HEADING.update(true)
@@ -194,9 +246,9 @@
194
246
  // Second pass: consume par buffer
195
247
  //
196
248
  // PAR_BUFFER item dictionary layout:
197
- // item.content — the paragraph body or table element
249
+ // item.content — the paragraph body, table element, or block-quote body
198
250
  // item.nest_level — nesting depth (−1 for tables)
199
- // item.kind — "par", "heading", "table", or "continuation"
251
+ // item.kind — "par", "heading", "table", "continuation", or "quote"
200
252
  context {
201
253
  let heading_buffer = none
202
254
  let heading_level = 0
@@ -230,13 +282,15 @@
230
282
  // A buffered heading runs into this element only when the two belong to
231
283
  // the same item: a later block of this list item ("continuation"), or,
232
284
  // at top level, the next paragraph. The first block of the *next* item,
233
- // a table (nest_level −1, so the level test alone excludes it), and
234
- // another heading all fail the test, and each would otherwise carry the
235
- // heading's text somewhere it was not authored. Those emit the heading
236
- // on its own line, the treatment a heading before a table takes.
285
+ // a table (nest_level −1, so the level test alone excludes it), a block
286
+ // quote (verbatim: a heading prepended to it would be ink the author did
287
+ // not put inside the quote), and another heading all fail the test, and
288
+ // each would otherwise carry the heading's text somewhere it was not
289
+ // authored. Those emit the heading on its own line, the treatment a
290
+ // heading before a table takes.
237
291
  if heading_buffer != none {
238
292
  let runs_in = (
239
- kind != "heading"
293
+ kind not in ("heading", "quote")
240
294
  and item.nest_level == heading_level
241
295
  and (heading_level == 0 or kind == "continuation")
242
296
  )
@@ -269,6 +323,35 @@
269
323
  let final_par = {
270
324
  if kind == "table" {
271
325
  render-memo-table(item_content)
326
+ } else if kind == "quote" {
327
+ // A block quote is the body's unlabeled block: no number, no letter,
328
+ // no bullet — the author's lines as written. It is placed, not
329
+ // processed, which is what lets a memorandum carry a roster of names,
330
+ // an address, or a quoted passage without AFH 33-337 numbering
331
+ // claiming the first line of it.
332
+ //
333
+ // Two things are imposed, and both serve that reading.
334
+ //
335
+ // Where the block starts: at the offset where the text of the
336
+ // paragraph it sits in starts, so a quote inside a subparagraph hangs
337
+ // under that subparagraph's text instead of falling back to the left
338
+ // margin. `pad` shifts the whole block, where a continuation takes a
339
+ // first-line `h(..)`: a quote is kept for its line structure, and
340
+ // lines that wrapped back to the margin would read as a block other
341
+ // than the one authored. Top level offsets by 0pt — flush at the
342
+ // margin, where an unnumbered line belongs.
343
+ //
344
+ // How its own paragraphs are spaced: `par.leading` and `par.spacing`
345
+ // are both half an em in a memorandum, so a paragraph break inside
346
+ // the quote would land on the page as an ordinary line break and read
347
+ // as one block where the author wrote two. A blank line — what the
348
+ // body puts between its own paragraphs — keeps that break visible.
349
+ let quoted = {
350
+ set par(spacing: spacing.line + line-stride())
351
+ item_content
352
+ }
353
+ let offset = paragraph-text-offset(nest_level, level-counts, indent-fn)
354
+ if offset == 0pt { quoted } else { pad(left: offset, quoted) }
272
355
  } else if kind == "continuation" {
273
356
  // Continuation block within a multi-block list item:
274
357
  // indent to align with preceding numbered paragraph's text, no new number.
@@ -27,11 +27,15 @@
27
27
  // Format of indorsement: "standard" (same page), "informal" (no header), or "separate_page" (starts on new page)
28
28
  format: "standard",
29
29
  // Decision action. `none` (default) displays no action line at all.
30
- // Approval authority pair: "approve" or "disapprove" circles the selected
31
- // option; "undecided" displays the pair with neither circled.
32
- // Coordination pair: "concur" or "nonconcur" circles the selected option;
33
- // "undecided_concur" displays the pair with neither circled.
30
+ // "approve" or "disapprove" underlines the selected option and strikes the
31
+ // other; "undecided" displays the pair with neither marked.
34
32
  action: none,
33
+ // Role this indorsement plays in the coordination chain, which selects the
34
+ // wording of the action line: the approval authority (the last indorsement
35
+ // in the chain) Approves / Disapproves, every coordinating official before
36
+ // it Concurs / Nonconcurs. The caller owns this because only it can see
37
+ // whether further indorsements follow.
38
+ approval_authority: false,
35
39
  content,
36
40
  ) = {
37
41
  // Validate format parameter
@@ -160,7 +164,11 @@
160
164
 
161
165
  // Show action line only when an action decision is set (not `none`)
162
166
  if effective_action != none {
163
- render-action-line(effective_action, trailing-blank-line: not body_empty)
167
+ render-action-line(
168
+ effective_action,
169
+ approval-authority: approval_authority,
170
+ trailing-blank-line: not body_empty,
171
+ )
164
172
  }
165
173
 
166
174
  if not body_empty {
@@ -55,3 +55,4 @@
55
55
  #import "mainmatter.typ": mainmatter
56
56
  #import "backmatter.typ": backmatter
57
57
  #import "indorsement.typ": indorsement
58
+ #import "utils.typ": date-pattern
@@ -306,37 +306,46 @@
306
306
  // plays in a coordination chain: coordinating officials Concur / Nonconcur,
307
307
  // while the final approval authority Approves / Disapproves.
308
308
  //
309
- // Each pair also has an "undecided" form that renders both options plain, for
310
- // printing a memo the endorser circles by hand when signing.
309
+ // Which pair prints is not the endorser's to choose — it follows from the
310
+ // role, so the caller supplies the role via `approval-authority` and the same
311
+ // three `action` values (affirm, reject, or leave open) carry over both pairs.
312
+ //
313
+ // The "undecided" form renders both options plain, for printing a memo the
314
+ // endorser marks by hand when signing.
311
315
  //
312
316
  // Empty/none suppression is handled by the caller before this is invoked.
313
317
 
314
- // Maps each `action` value to the option pair it renders and the index of the
315
- // selected option within that pair (`none` selects neither).
316
- #let ACTION_FORMS = (
317
- "undecided": (("Approve", "Disapprove"), none),
318
- "approve": (("Approve", "Disapprove"), 0),
319
- "disapprove": (("Approve", "Disapprove"), 1),
320
- "undecided_concur": (("Concur", "Nonconcur"), none),
321
- "concur": (("Concur", "Nonconcur"), 0),
322
- "nonconcur": (("Concur", "Nonconcur"), 1),
318
+ // The option pair for each role, ordered (affirmative, negative).
319
+ #let APPROVAL_OPTIONS = ("Approve", "Disapprove")
320
+ #let COORDINATION_OPTIONS = ("Concur", "Nonconcur")
321
+
322
+ // Maps each `action` value to the index of the option it selects within
323
+ // whichever pair is rendered (`none` selects neither).
324
+ #let ACTION_SELECTIONS = (
325
+ "undecided": none,
326
+ "approve": 0,
327
+ "disapprove": 1,
323
328
  )
324
329
 
325
- #let render-action-line(action, trailing-blank-line: true) = {
330
+ #let render-action-line(action, approval-authority: false, trailing-blank-line: true) = {
326
331
  assert(
327
- action in ACTION_FORMS,
328
- message: "action must be one of " + ACTION_FORMS.keys().map(k => "\"" + k + "\"").join(", "),
332
+ action in ACTION_SELECTIONS,
333
+ message: "action must be one of " + ACTION_SELECTIONS.keys().map(k => "\"" + k + "\"").join(", "),
329
334
  )
330
- let (options, selected) = ACTION_FORMS.at(action)
331
- // Circle the selected option using a box with rounded corners; strike the
332
- // one it was chosen over. When neither is selected both render plain.
333
- // Use baseline parameter to maintain vertical text alignment.
335
+ let options = if approval-authority { APPROVAL_OPTIONS } else { COORDINATION_OPTIONS }
336
+ let selected = ACTION_SELECTIONS.at(action)
337
+ // Underline the selected option; strike the one it was chosen over. When
338
+ // neither is selected both render plain. An underline keeps the option on
339
+ // the line's own baseline — the box it replaces had to be nudged with
340
+ // `baseline` to sit straight — and reads as a mark made on the page, where a
341
+ // ruled rectangle in a PDF carrying real AcroForm widgets reads as one more
342
+ // fillable field.
334
343
  let render-option(index) = {
335
344
  let option = options.at(index)
336
345
  if selected == none {
337
346
  option
338
347
  } else if selected == index {
339
- box(stroke: 0.5pt + black, radius: 2pt, inset: 2pt, baseline: 2pt)[#option]
348
+ underline(option)
340
349
  } else {
341
350
  strike(option)
342
351
  }
@@ -18,26 +18,33 @@
18
18
  // Value is a `length` set once in `frontmatter`.
19
19
  #let LINE_STRIDE = state("LINE_STRIDE")
20
20
 
21
+ /// The height one wrapped line of body text occupies.
22
+ ///
23
+ /// Read from `LINE_STRIDE` (cached in `frontmatter`), falling back to an inline
24
+ /// measurement when the cache is unset. Reads state, so callers must already be
25
+ /// in a `context`.
26
+ ///
27
+ /// -> length
28
+ #let line-stride() = {
29
+ let stride = LINE_STRIDE.get()
30
+ if stride == none {
31
+ let one-line = measure(par(spacing: 0pt)[x]).height
32
+ stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
33
+ }
34
+ stride
35
+ }
36
+
21
37
  /// Creates vertical spacing equivalent to multiple blank lines.
22
38
  ///
23
39
  /// Adds `count` wrapped-line strides on top of the natural inter-paragraph
24
40
  /// gap, so a blank line occupies exactly the same vertical space as a line
25
- /// produced by natural paragraph wrapping. The stride is measured from
26
- /// `LINE_STRIDE` (cached in `frontmatter`) and falls back to an inline
27
- /// measurement when the cache is unset.
41
+ /// produced by natural paragraph wrapping.
28
42
  ///
29
43
  /// - count (int): Number of blank lines to create
30
44
  /// -> content
31
45
  #let blank-lines(count) = {
32
46
  if count <= 0 { return }
33
- context {
34
- let stride = LINE_STRIDE.get()
35
- if stride == none {
36
- let one-line = measure(par(spacing: 0pt)[x]).height
37
- stride = measure(par(spacing: 0pt)[x#linebreak()x]).height - one-line
38
- }
39
- v(stride * count)
40
- }
47
+ context { v(line-stride() * count) }
41
48
  }
42
49
 
43
50
  /// Creates vertical spacing equivalent to one blank line.
@@ -129,17 +136,24 @@
129
136
  ]
130
137
  }
131
138
 
139
+ /// The date pattern a memo style prints, per AFH 33-337. Exported so a caller
140
+ /// that pre-formats the date matches this package instead of restating it.
141
+ ///
142
+ /// - memo-style (str): `"usaf"` or `"daf"`
143
+ /// -> str
144
+ #let date-pattern(memo-style: "usaf") = if memo-style == "daf" {
145
+ "[month repr:long] [day padding:none], [year]"
146
+ } else {
147
+ "[day padding:none] [month repr:long] [year]"
148
+ }
149
+
132
150
  /// Formats a date for the memo heading.
133
151
  ///
134
- /// Dispatches on the shape a date field can arrive in:
135
- /// - str: shown as-is (fixed text like a placeholder).
136
- /// - datetime: a native Typst datetime (e.g. `datetime.today()`) — USAF style
137
- /// `DD Month YYYY`, DAF style `Month DD, YYYY`.
138
- /// - dict: a Quillmark `date`/`datetime` field lowers to a click-to-edit value
139
- /// object `{ value: datetime, display: closure }`; `display` is a stored closure
140
- /// over `.display`, so call it through parentheses — a dict has no `.display` method.
152
+ /// - str, content: shown as-is (a fixed placeholder, or ink the caller already
153
+ /// formatted through [`date-pattern`]).
154
+ /// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
141
155
  ///
142
- /// - date (str | datetime | dict): Date to format for display
156
+ /// - date (str | content | datetime): Date to format for display
143
157
  /// - memo-style (str): `"usaf"` or `"daf"`
144
158
  /// -> content
145
159
  #let display-date(date, memo-style: "usaf") = {
@@ -147,19 +161,12 @@
147
161
  memo-style in ("usaf", "daf"),
148
162
  message: "memo-style for display-date must be \"usaf\" or \"daf\"",
149
163
  )
150
- if type(date) == str {
164
+ // A caller that pre-formatted — to keep a click-to-edit region the package
165
+ // cannot mint itself — passes the finished ink through untouched.
166
+ if type(date) in (str, content) {
151
167
  date
152
168
  } else {
153
- let pattern = if memo-style == "daf" {
154
- "[month repr:long] [day padding:none], [year]"
155
- } else {
156
- "[day padding:none] [month repr:long] [year]"
157
- }
158
- if type(date) == datetime {
159
- date.display(pattern)
160
- } else {
161
- (date.display)(pattern)
162
- }
169
+ date.display(date-pattern(memo-style: memo-style))
163
170
  }
164
171
  }
165
172
 
@@ -1,5 +1,13 @@
1
- #import "@local/quillmark-helper:0.1.0": data, form-field, signature-field
2
- #import "@local/tonguetoquill-usaf-memo:4.0.0": backmatter, frontmatter, indorsement, mainmatter
1
+ #import "@local/quillmark-helper:0.1.0": (
2
+ data, display, field-region, form-field, signature-field,
3
+ )
4
+ #import "@local/tonguetoquill-usaf-memo:4.0.0": (
5
+ backmatter, date-pattern, frontmatter, indorsement, mainmatter,
6
+ )
7
+
8
+ // A memo has no "no style" state, so the blank takes the package's default.
9
+ // Resolved once here: `frontmatter` and the date's pattern must agree.
10
+ #let memo_style = if data.memo_style != "" { data.memo_style } else { "usaf" }
3
11
 
4
12
  // The letterhead arrives as one ordered array of lines. Element 0 is the title,
5
13
  // set larger; the rest are caption lines beneath it. A single element renders
@@ -31,8 +39,28 @@
31
39
  ))
32
40
  },
33
41
 
34
- // Date
35
- date: data.date,
42
+ // Date. `data.date` is the native `datetime` and would render identically,
43
+ // but its ink would be born inside the package and carry no schema address.
44
+ // `display` places the field's *content* projection instead: the glyphs are
45
+ // born in the generated helper, so the memo date stays click-to-edit however
46
+ // deep the package formats it.
47
+ //
48
+ // A blank date means today's, and the plate stamps it rather than falling
49
+ // through to `frontmatter`'s own `datetime.today()`: package-born ink carries
50
+ // no address, so the one date a memo never types would be the one date a
51
+ // preview cannot click. `field-region` claims that ink for the field instead.
52
+ //
53
+ // The stamp is markup, not the bare `str` `.display()` returns: a `str` off a
54
+ // function call carries no source position, and ink with none is unclaimable.
55
+ date: {
56
+ let pattern = date-pattern(memo-style: memo_style)
57
+ let authored = display("date", pattern)
58
+ if authored != none {
59
+ authored
60
+ } else {
61
+ field-region("date", [#datetime.today().display(pattern)])
62
+ }
63
+ },
36
64
 
37
65
  // Receiver information
38
66
  memo_for: data.memo_for,
@@ -49,20 +77,28 @@
49
77
  // Optional footer tag line
50
78
  footer_tag_line: data.tag_line,
51
79
 
52
- // Optional classification level
53
- ..if data.classification != "" { (classification_level: data.classification) },
80
+ // The blank reads as no banner, which is what the package's own
81
+ // `classification_level: none` default means.
82
+ classification_level: data.classification.value,
54
83
 
55
84
  dissemination: data.dissemination,
56
85
 
57
- // CUI designation indicator block fields (DoDM 5200.48)
58
- cui_controlled_by: data.cui_controlled_by,
59
- cui_category: data.cui_category,
60
- cui_limited_dissemination: data.cui_limited_dissemination,
61
- cui_poc: data.cui_poc,
86
+ // CUI designation indicator block fields (DoDM 5200.48). `classification`
87
+ // declares a `CUI` variant, so these four exist only where the discriminant
88
+ // reads CUI, and the branch is what makes reading them total: inside it every
89
+ // declared field of that world is present, outside it none is. The package's
90
+ // own `cui_*: none` defaults cover the worlds that omit them.
91
+ ..if data.classification.value == "CUI" {
92
+ (
93
+ cui_controlled_by: data.classification.controlled_by,
94
+ cui_category: data.classification.category,
95
+ cui_limited_dissemination: data.classification.limited_dissemination,
96
+ cui_poc: data.classification.poc,
97
+ )
98
+ },
62
99
 
63
- // USAF vs DAF memorandum style (date format, body indentation). Blank
64
- // falls through to the package's `"usaf"` default.
65
- ..if data.memo_style != "" { (memo_style: data.memo_style) },
100
+ // USAF vs DAF memorandum style (date format, body indentation).
101
+ memo_style: memo_style,
66
102
 
67
103
  // Font size
68
104
  font_size: body_font_size,
@@ -95,6 +131,26 @@
95
131
  ..if data.attachments.len() > 0 { (attachments: data.attachments) },
96
132
  )
97
133
 
134
+ // The action line's wording follows the indorsement's place in the chain, not
135
+ // an endorser's choice: the last indorsement is the approval authority and
136
+ // reads Approve / Disapprove, every one before it is a coordinating official
137
+ // and reads Concur / Nonconcur. Only this loop can see whether an indorsement
138
+ // is the last one, so the position is resolved here and passed down. Cards of
139
+ // other kinds may interleave, so the last indorsement is the last card *of
140
+ // this kind*, not the last card. `-1` never matches a real index, which is the
141
+ // wanted result when there are no indorsements at all.
142
+ // Parenthesized so the method chain can span lines: in markup, a `#let` whose
143
+ // expression ends at a line break stops there, and the next line's leading
144
+ // `.at(…)` would be read as text.
145
+ #let last_indorsement_index = (
146
+ data
147
+ .at("$cards")
148
+ .enumerate()
149
+ .filter(entry => entry.at(1).at("$kind", default: none) == "indorsement")
150
+ .map(entry => entry.at(0))
151
+ .at(-1, default: -1)
152
+ )
153
+
98
154
  // Indorsements - iterate through CARDS array and filter by CARD tag
99
155
  #for (i, card) in data.at("$cards").enumerate() {
100
156
  if card.at("$kind", default: none) == "indorsement" {
@@ -108,12 +164,13 @@
108
164
  // it (distinct from the originating memo's date). The signing date is
109
165
  // generally unknown at compile time, so a blank or omitted date leaves the
110
166
  // date slot fillable rather than stamping the compile date into it.
111
- let card_date = card.date
112
- let resolved_date = if card_date == none or card_date == "" {
113
- none
114
- } else {
115
- card_date
116
- }
167
+ // `display` takes an address, so a per-card call yields a per-card region
168
+ // even though every iteration shares one `card` loop variable, and it
169
+ // returns `none` for a blank date — the fill-in case below.
170
+ let resolved_date = display(
171
+ card.at("$path") + "date",
172
+ date-pattern(memo-style: memo_style),
173
+ )
117
174
  // The card's `$path` prefix composes its canonical schema addresses
118
175
  // (`$cards.indorsement.<n>.…`, per-kind ordinal) — the absolute loop
119
176
  // index `i` is NOT that ordinal once kinds interleave, so it stays a
@@ -144,6 +201,7 @@
144
201
  ))
145
202
  },
146
203
  ..if card.action != "" { (action: card.action) },
204
+ approval_authority: i == last_indorsement_index,
147
205
  body_content,
148
206
  )
149
207
  }