diff --git a/README.md b/README.md index 37eba6d..9584432 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,7 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a |---|---|---| | `font` | `"Lato"` | Primary font family. Must be installed. | | `bodySize` | `10pt` | Base text size. Sub-elements scale via em-multipliers. | +| `density` | `"comfortable"` | Single knob that scales every vertical-spacing em-token uniformly — block above/below, `v()` gaps, `par` leading/spacing, `list` spacing. `"compact"` (× 0.85) tightens for a one-page fit, `"comfortable"` (× 1.0) is the historical default, `"spacious"` (× 1.15) opens it up for print presentation. Text sizes, icon dimensions, and rating-dot geometry are unaffected — use `bodySize` for those. Unknown values panic. | | `paper` | `"a4"` | Paper size string passed to Typst's `set page(paper: ...)`. `"a4"`, `"us-letter"`, `"a5"`, `"us-legal"`, and the rest of [Typst's named papers](https://typst.app/docs/reference/layout/page/#parameters-paper). | | `margin` | `(x: 0.9cm, y: 1.5cm)` | Page margins. Anything `set page(margin: ...)` accepts. | | `accent` | `palettes.teal` | Theme colour for headings, accent rules, tags, dots. Use a built-in preset — `palettes.{teal,navy,crimson,forest,plum,charcoal}` — or any `rgb(...)` value. | diff --git a/examples/tests/density.pdf b/examples/tests/density.pdf new file mode 100644 index 0000000..63fd6d7 Binary files /dev/null and b/examples/tests/density.pdf differ diff --git a/internal/header.typ b/internal/header.typ index bcd5d75..1c9dcc8 100644 --- a/internal/header.typ +++ b/internal/header.typ @@ -5,7 +5,7 @@ // `_format_location` / `_url_encode` helpers that feed the maps deep // link (single-caller helpers, kept adjacent to their consumer). -#import "state.typ": _body_size_state, _accent_state, _emphasis_colour +#import "state.typ": _body_size_state, _accent_state, _spacing_scale_state, _emphasis_colour #import "presets.typ": maps-providers #import "icons.typ": icon, _profile_networks, _network_aliases @@ -146,11 +146,12 @@ context { let body-size = _body_size_state.get() let accent = _accent_state.get() + let scale = _spacing_scale_state.get() let header-text = align(text-align, { block( spacing: 0pt, - below: 1.2 * body-size, + below: 1.2 * scale * body-size, text( 2.5 * body-size, fill: accent, @@ -162,7 +163,7 @@ if "label" in basics and basics.label != none { block( spacing: 0pt, - below: 0.8 * body-size, + below: 0.8 * scale * body-size, text(1.2 * body-size, fill: _emphasis_colour, weight: "bold", basics.label), ) } @@ -308,7 +309,7 @@ // the document width, regardless of how the text above / // below is aligned. let centred-photo = block( - spacing: 0.8 * body-size, + spacing: 0.8 * scale * body-size, width: 100%, align(center, photo), ) @@ -335,7 +336,8 @@ let summary = basics.at("summary", default: none) if summary == none or summary == "" or summary == [] { return } let body-size = _body_size_state.get() - v(0.8 * body-size) + let scale = _spacing_scale_state.get() + v(0.8 * scale * body-size) par(summary) - v(0.4 * body-size) + v(0.4 * scale * body-size) } diff --git a/internal/layout.typ b/internal/layout.typ index dde8b27..cd31888 100644 --- a/internal/layout.typ +++ b/internal/layout.typ @@ -90,6 +90,12 @@ // Every spacing token is an em-multiplier of this, so changing one // knob scales the whole document proportionally. bodySize: 10pt, + // Scales every spacing em-token uniformly (see `_density_scales` + // in `internal/state.typ`). `"comfortable"` (1.0×) preserves the + // historical layout byte-for-byte; `"compact"` (0.85×) tightens + // for one-page CVs; `"spacious"` (1.15×) opens it up for print + // presentation. Text sizes are unaffected. + density: "comfortable", paper: "a4", margin: (x: 0.9cm, y: 1.5cm), // `palettes.teal` — see the `palettes` dict for the curated set diff --git a/internal/primitives.typ b/internal/primitives.typ index e917a64..492f4a1 100644 --- a/internal/primitives.typ +++ b/internal/primitives.typ @@ -3,7 +3,7 @@ // from `lib.typ` for callers composing custom layouts; the leading- // underscore helpers are used internally by the section renderers. -#import "state.typ": _body_size_state, _accent_state, _body_colour, _divider_colour +#import "state.typ": _body_size_state, _accent_state, _spacing_scale_state, _body_colour, _divider_colour #import "icons.typ": icon // Bold accent-coloured line — designed for the company / institution @@ -11,9 +11,10 @@ #let name(body) = context { let body-size = _body_size_state.get() let accent = _accent_state.get() + let scale = _spacing_scale_state.get() block( above: 0pt, - below: 0.6 * body-size, + below: 0.6 * scale * body-size, text(weight: "bold", fill: accent, body), ) } @@ -23,9 +24,10 @@ #let term(period, location: none) = context { if period == none and location == none { return } let body-size = _body_size_state.get() + let scale = _spacing_scale_state.get() block( above: 0pt, - below: 0.8 * body-size, + below: 0.8 * scale * body-size, inset: (left: 0.3 * body-size), text(0.9 * body-size, { if period != none { @@ -77,12 +79,13 @@ #let divider() = context { let body-size = _body_size_state.get() if here().position().y < 2cm { return } - v(0.3 * body-size) + let scale = _spacing_scale_state.get() + v(0.3 * scale * body-size) line( length: 100%, stroke: (paint: _divider_colour, thickness: 0.6pt, dash: "dashed"), ) - v(0.3 * body-size) + v(0.3 * scale * body-size) } // Like `divider()` but with a leading label that sits slightly indented @@ -94,8 +97,9 @@ // the parent section title. #let _labelled_divider(label) = context { let body-size = _body_size_state.get() + let scale = _spacing_scale_state.get() let stroke = (paint: _divider_colour, thickness: 0.6pt, dash: "dashed") - v(0.3 * body-size) + v(0.3 * scale * body-size) pad(left: 0.6 * body-size, grid( columns: (1.3em, auto, 1fr), column-gutter: 0.5 * body-size, @@ -108,7 +112,7 @@ ), line(length: 100%, stroke: stroke), )) - v(0.3 * body-size) + v(0.3 * scale * body-size) } // Interleaves `divider()` between items; the trailing one is suppressed diff --git a/internal/state.typ b/internal/state.typ index 1e148e7..1029111 100644 --- a/internal/state.typ +++ b/internal/state.typ @@ -11,6 +11,23 @@ #let _body_size_state = state("alta-body-size", 10pt) #let _accent_state = state("alta-accent", palettes.teal) #let _max_rating_state = state("alta-max-rating", 5) +// Multiplier applied to every spacing em-token (block above/below, +// `v()`, par.spacing/leading, list.spacing). Driven by +// `preferences.density`; defaults to 1.0 so "comfortable" reproduces +// the historical layout byte-for-byte. +#let _spacing_scale_state = state("alta-spacing-scale", 1.0) + +// `preferences.density` → multiplier on every spacing em-token. +// 0.85 / 1.0 / 1.15 keeps the three presets visibly distinct without +// either crushing lines together or pushing the one-page CV onto two. +// Text sizes, icon dimensions, and rating-dot geometry are +// deliberately left alone — density is purely vertical whitespace, +// so font-size scaling stays the job of `bodySize`. +#let _density_scales = ( + compact: 0.85, + comfortable: 1.0, + spacious: 1.15, +) // Accent is configurable via `alta(preferences: (accent: ...))`; the // rest are opinionated visual constants. diff --git a/lib.typ b/lib.typ index 23d178e..d1dfed4 100644 --- a/lib.typ +++ b/lib.typ @@ -19,7 +19,7 @@ // independent of text size. #import "internal/presets.typ": palettes, maps-providers -#import "internal/state.typ": _body_size_state, _accent_state, _max_rating_state, _body_colour, _emphasis_colour +#import "internal/state.typ": _body_size_state, _accent_state, _max_rating_state, _spacing_scale_state, _density_scales, _body_colour, _emphasis_colour #import "internal/defaults.typ": _default_labels #import "internal/validation.typ": _strict_merge, _check_bool #import "internal/text.typ": _present, styled-link @@ -140,9 +140,19 @@ } let accent = preferences.accent let body-size = preferences.bodySize + let density = preferences.density + if density not in _density_scales { + let quote(k) = "\"" + k + "\"" + panic( + "density must be one of " + _density_scales.keys().map(quote).join(", ") + + ", got: " + repr(density), + ) + } + let scale = _density_scales.at(density) _accent_state.update(accent) _body_size_state.update(body-size) _max_rating_state.update(max-rating) + _spacing_scale_state.update(scale) // PDF metadata is sourced from `basics` (title, author, description) // and the JSON Resume `meta` block (date, keywords). Each optional @@ -191,12 +201,12 @@ margin: preferences.margin, footer: resolved-footer, ) - set par(leading: 0.55em, spacing: 0.7em) + set par(leading: 0.55 * scale * 1em, spacing: 0.7 * scale * 1em) set list( marker: text(0.85em, "•"), indent: 0pt, body-indent: 0.4 * body-size, - spacing: 0.55em, + spacing: 0.55 * scale * 1em, ) // Heading levels map to semantic CV roles: @@ -204,21 +214,21 @@ // === role / qualification line // ==== sub-grouping (publication type) show heading.where(level: 2): it => block(sticky: true)[ - #v(0.6 * body-size) + #v(0.6 * scale * body-size) #text(1.7 * body-size, fill: accent, weight: "bold", upper(it.body)) - #v(-0.7 * body-size) + #v(-0.7 * scale * body-size) #line(length: 100%, stroke: 2pt + accent) - #v(0.2 * body-size) + #v(0.2 * scale * body-size) ] show heading.where(level: 3): it => block( - above: 1.0 * body-size, - below: 0.8 * body-size, + above: 1.0 * scale * body-size, + below: 0.8 * scale * body-size, sticky: true, text(1.2 * body-size, fill: _emphasis_colour, weight: "regular", it.body), ) show heading.where(level: 4): it => block( - above: 0.6 * body-size, - below: 0.6 * body-size, + above: 0.6 * scale * body-size, + below: 0.6 * scale * body-size, sticky: true, text(1.2 * body-size, fill: _emphasis_colour, weight: "bold", it.body), ) diff --git a/sections/projects.typ b/sections/projects.typ index 1a9457f..dfc51e1 100644 --- a/sections/projects.typ +++ b/sections/projects.typ @@ -7,6 +7,7 @@ #import "../internal/text.typ": _present, styled-link #import "../internal/primitives.typ": term, _join_with_dividers, _tag_row #import "../internal/dates.typ": _format_date_range +#import "../internal/state.typ": _spacing_scale_state, _body_size_state #let _projects(entries, labels, prefs) = { let valid = entries.filter(p => _present(p.at("name", default: none))) @@ -19,11 +20,14 @@ if _present(description) { // Softer than `name()` (which is bold + accent) so the // description doesn't compete visually with a linked title. - // Wrapped in a block so the gap to the term row below matches - // the institution-line → term spacing in `_experience` / - // `_education`; using a bare `linebreak()` here leaves no - // paragraph spacing. - block(below: 0.6em, emph(description)) + // Mirrors `name()`'s `below` so the description → term gap + // matches the institution-line → term gap in `_experience` / + // `_education`; literal `0.6em` would skip the density scale + // and break that contract under non-default density. + context block( + below: 0.6 * _spacing_scale_state.get() * _body_size_state.get(), + emph(description), + ) } term(_format_date_range(project, prefs, labels)) for bullet in project.at("highlights", default: ()) [- #bullet] diff --git a/sections/skills.typ b/sections/skills.typ index ac2fc77..02d33ca 100644 --- a/sections/skills.typ +++ b/sections/skills.typ @@ -4,7 +4,7 @@ // `_name_keywords_section`; the two public renderers differ only in // which `labels.*` heading they pass. -#import "../internal/state.typ": _body_size_state +#import "../internal/state.typ": _body_size_state, _spacing_scale_state #import "../internal/primitives.typ": tag, _tag_row // `text("-")` (not `[-]`) — markup-bracketed `-` parses as a list-item @@ -20,7 +20,8 @@ if visible.len() == 0 { return } context { let body-size = _body_size_state.get() - let row-gap = 0.7 * body-size + let scale = _spacing_scale_state.get() + let row-gap = 0.7 * scale * body-size [== #heading] for group in visible { block(above: 0pt, below: row-gap, par(hanging-indent: 1em, leading: row-gap, { diff --git a/tests/density.typ b/tests/density.typ new file mode 100644 index 0000000..bea53a1 --- /dev/null +++ b/tests/density.typ @@ -0,0 +1,64 @@ +// `preferences.density` scales every spacing em-token uniformly so a +// single knob trades vertical breathing room for fit-on-one-page. +// Three pages exercise each preset against the same data; visual diff +// against the default ("comfortable") confirms the multiplier reaches +// section headings, divider rules, par.spacing, the in-section block +// above/below tokens, and the header / summary gaps. + +#import "../lib.typ": alta + +#let cv = ( + basics: ( + name: "Jane Doe", + label: "Senior Software Engineer", + summary: [Backend engineer; the summary block exercises the + pre/post `v()` gaps that flank it.], + email: "jane@example.com", + phone: "+353 1 555 0100", + location: "Dublin, Ireland", + ), + work: ( + ( + name: "Acme Corp", + position: "Senior Software Engineer", + location: "Dublin, Ireland", + startDate: "Jan 2022", + highlights: ([Led the migration.], [Designed the platform.]), + ), + ( + name: "Foo Ltd", + position: "Software Engineer", + startDate: "Jan 2018", + endDate: "Dec 2021", + highlights: ([Shipped the thing.],), + ), + ), + skills: ( + (name: "Languages", keywords: ("Scala", "Python")), + (name: "Infra", keywords: ("Kafka", "AWS", "Kubernetes")), + ), + languages: ( + (language: "English", fluency: "Native"), + (language: "Irish", fluency: "Professional Working"), + ), + education: ( + (institution: "Example U", studyType: "B.Sc.", endDate: "2017"), + ), + certificates: ( + (name: "CKA", issuer: "CNCF"), + ), +) + +// 1. Compact — em spacing tokens × 0.85. Tighter than the default. +#alta(cv, preferences: (density: "compact")) + +#pagebreak() + +// 2. Comfortable — em spacing tokens × 1.0. The default; explicit +// here so the regression is anchored even if the default changes. +#alta(cv, preferences: (density: "comfortable")) + +#pagebreak() + +// 3. Spacious — em spacing tokens × 1.15. Roomier than the default. +#alta(cv, preferences: (density: "spacious"))