@airmark/quiver 0.31.0 → 0.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airmark/quiver",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
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.24.0",
20
+ "@quillmark/wasm": "^0.108.1",
21
+ "quillkit": "0.5.1"
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 ──────────────────
@@ -122,6 +122,28 @@ main:
122
122
  group: classification
123
123
  compact: true
124
124
  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."
125
+ variants:
126
+ CUI:
127
+ controlled_by:
128
+ type: plaintext
129
+ inline: true
130
+ example: SAF/AA
131
+ description: "Office or organization that designated this information as CUI. Required by DoDM 5200.48."
132
+ poc:
133
+ type: plaintext
134
+ inline: true
135
+ example: "Capt J. Smith, DSN 555-1234, john.smith@us.af.mil"
136
+ description: "Point of contact for CUI questions. Required by DoDM 5200.48."
137
+ category:
138
+ type: plaintext
139
+ inline: true
140
+ default: ""
141
+ description: "CUI category from the DoD CUI Registry (e.g., 'Privacy/MED', 'CTI', 'PRVCY'). Leave blank to omit."
142
+ limited_dissemination:
143
+ type: plaintext
144
+ inline: true
145
+ default: ""
146
+ description: "Limited dissemination control shown in the indicator block (e.g., 'FEDONLY', 'NOFORN'). Independent from the banner suffix."
125
147
 
126
148
  dissemination:
127
149
  type: plaintext
@@ -132,42 +154,6 @@ main:
132
154
  compact: true
133
155
  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
156
 
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
157
  references:
172
158
  type: array
173
159
  items:
@@ -297,14 +283,11 @@ card_kinds:
297
283
  - undecided
298
284
  - approve
299
285
  - disapprove
300
- - undecided_concur
301
- - concur
302
- - nonconcur
303
286
  default: ""
304
287
  ui:
305
288
  group: additional
306
289
  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)."
290
+ 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."
308
291
  date:
309
292
  type: date
310
293
  default: ""
@@ -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
  }
@@ -129,17 +129,24 @@
129
129
  ]
130
130
  }
131
131
 
132
+ /// The date pattern a memo style prints, per AFH 33-337. Exported so a caller
133
+ /// that pre-formats the date matches this package instead of restating it.
134
+ ///
135
+ /// - memo-style (str): `"usaf"` or `"daf"`
136
+ /// -> str
137
+ #let date-pattern(memo-style: "usaf") = if memo-style == "daf" {
138
+ "[month repr:long] [day padding:none], [year]"
139
+ } else {
140
+ "[day padding:none] [month repr:long] [year]"
141
+ }
142
+
132
143
  /// Formats a date for the memo heading.
133
144
  ///
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.
145
+ /// - str, content: shown as-is (a fixed placeholder, or ink the caller already
146
+ /// formatted through [`date-pattern`]).
147
+ /// - datetime: USAF style `DD Month YYYY`; DAF style `Month DD, YYYY`.
141
148
  ///
142
- /// - date (str | datetime | dict): Date to format for display
149
+ /// - date (str | content | datetime): Date to format for display
143
150
  /// - memo-style (str): `"usaf"` or `"daf"`
144
151
  /// -> content
145
152
  #let display-date(date, memo-style: "usaf") = {
@@ -147,19 +154,12 @@
147
154
  memo-style in ("usaf", "daf"),
148
155
  message: "memo-style for display-date must be \"usaf\" or \"daf\"",
149
156
  )
150
- if type(date) == str {
157
+ // A caller that pre-formatted — to keep a click-to-edit region the package
158
+ // cannot mint itself — passes the finished ink through untouched.
159
+ if type(date) in (str, content) {
151
160
  date
152
161
  } 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
- }
162
+ date.display(date-pattern(memo-style: memo-style))
163
163
  }
164
164
  }
165
165
 
@@ -1,5 +1,11 @@
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": data, display, form-field, signature-field
2
+ #import "@local/tonguetoquill-usaf-memo:4.0.0": (
3
+ backmatter, date-pattern, frontmatter, indorsement, mainmatter,
4
+ )
5
+
6
+ // A memo has no "no style" state, so the blank takes the package's default.
7
+ // Resolved once here: `frontmatter` and the date's pattern must agree.
8
+ #let memo_style = if data.memo_style != "" { data.memo_style } else { "usaf" }
3
9
 
4
10
  // The letterhead arrives as one ordered array of lines. Element 0 is the title,
5
11
  // set larger; the rest are caption lines beneath it. A single element renders
@@ -31,8 +37,13 @@
31
37
  ))
32
38
  },
33
39
 
34
- // Date
35
- date: data.date,
40
+ // Date. `data.date` is the native `datetime` and would render identically,
41
+ // but its ink would be born inside the package and carry no schema address.
42
+ // `display` places the field's *content* projection instead: the glyphs are
43
+ // born in the generated helper, so the memo date stays click-to-edit however
44
+ // deep the package formats it. A blank date yields `none`, which is what
45
+ // `frontmatter`'s `datetime.today()` fallback keys on.
46
+ date: display("date", date-pattern(memo-style: memo_style)),
36
47
 
37
48
  // Receiver information
38
49
  memo_for: data.memo_for,
@@ -49,20 +60,28 @@
49
60
  // Optional footer tag line
50
61
  footer_tag_line: data.tag_line,
51
62
 
52
- // Optional classification level
53
- ..if data.classification != "" { (classification_level: data.classification) },
63
+ // The blank reads as no banner, which is what the package's own
64
+ // `classification_level: none` default means.
65
+ classification_level: data.classification.value,
54
66
 
55
67
  dissemination: data.dissemination,
56
68
 
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,
69
+ // CUI designation indicator block fields (DoDM 5200.48). `classification`
70
+ // declares a `CUI` variant, so these four exist only where the discriminant
71
+ // reads CUI, and the branch is what makes reading them total: inside it every
72
+ // declared field of that world is present, outside it none is. The package's
73
+ // own `cui_*: none` defaults cover the worlds that omit them.
74
+ ..if data.classification.value == "CUI" {
75
+ (
76
+ cui_controlled_by: data.classification.controlled_by,
77
+ cui_category: data.classification.category,
78
+ cui_limited_dissemination: data.classification.limited_dissemination,
79
+ cui_poc: data.classification.poc,
80
+ )
81
+ },
62
82
 
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) },
83
+ // USAF vs DAF memorandum style (date format, body indentation).
84
+ memo_style: memo_style,
66
85
 
67
86
  // Font size
68
87
  font_size: body_font_size,
@@ -95,6 +114,26 @@
95
114
  ..if data.attachments.len() > 0 { (attachments: data.attachments) },
96
115
  )
97
116
 
117
+ // The action line's wording follows the indorsement's place in the chain, not
118
+ // an endorser's choice: the last indorsement is the approval authority and
119
+ // reads Approve / Disapprove, every one before it is a coordinating official
120
+ // and reads Concur / Nonconcur. Only this loop can see whether an indorsement
121
+ // is the last one, so the position is resolved here and passed down. Cards of
122
+ // other kinds may interleave, so the last indorsement is the last card *of
123
+ // this kind*, not the last card. `-1` never matches a real index, which is the
124
+ // wanted result when there are no indorsements at all.
125
+ // Parenthesized so the method chain can span lines: in markup, a `#let` whose
126
+ // expression ends at a line break stops there, and the next line's leading
127
+ // `.at(…)` would be read as text.
128
+ #let last_indorsement_index = (
129
+ data
130
+ .at("$cards")
131
+ .enumerate()
132
+ .filter(entry => entry.at(1).at("$kind", default: none) == "indorsement")
133
+ .map(entry => entry.at(0))
134
+ .at(-1, default: -1)
135
+ )
136
+
98
137
  // Indorsements - iterate through CARDS array and filter by CARD tag
99
138
  #for (i, card) in data.at("$cards").enumerate() {
100
139
  if card.at("$kind", default: none) == "indorsement" {
@@ -108,12 +147,13 @@
108
147
  // it (distinct from the originating memo's date). The signing date is
109
148
  // generally unknown at compile time, so a blank or omitted date leaves the
110
149
  // 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
- }
150
+ // `display` takes an address, so a per-card call yields a per-card region
151
+ // even though every iteration shares one `card` loop variable, and it
152
+ // returns `none` for a blank date — the fill-in case below.
153
+ let resolved_date = display(
154
+ card.at("$path") + "date",
155
+ date-pattern(memo-style: memo_style),
156
+ )
117
157
  // The card's `$path` prefix composes its canonical schema addresses
118
158
  // (`$cards.indorsement.<n>.…`, per-kind ordinal) — the absolute loop
119
159
  // index `i` is NOT that ordinal once kinds interleave, so it stays a
@@ -144,6 +184,7 @@
144
184
  ))
145
185
  },
146
186
  ..if card.action != "" { (action: card.action) },
187
+ approval_authority: i == last_indorsement_index,
147
188
  body_content,
148
189
  )
149
190
  }