@kolkrabbi/kol-resume 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @kolkrabbi/kol-resume
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The resume kit: a resume in three treatments, the cover, letter, portfolio and back pages, the A4 and continuous kit, and its PDF export. The person comes through `ResumeProvider`, and nobody's details ship.
4
+
5
+ ```jsx
6
+ import { ResumeProvider, ResumeLibrary, ResumeSource, ResumeTreatment } from '@kolkrabbi/kol-resume'
7
+
8
+ <ResumeProvider person={{ BIO, SKILLS, LANGUAGES }} records={{ WORK, EDUCATION }} contact={BRAND_INFO}
9
+ letters={import.meta.glob('./letters/*.md', { query: '?raw', import: 'default', eager: true })}
10
+ portfolio={import.meta.glob('./portfolio/*.md', { query: '?raw', import: 'default', eager: true })}
11
+ assetUrl={(key) => `/bucket/${key}`} portrait="portraits/me.jpg" fileStem="ada-lovelace">
12
+ <Routes>
13
+ <Route path="/resume/library" element={<ResumeLibrary />} />
14
+ <Route path="/resume/source" element={<ResumeSource />} />
15
+ <Route path="/resume/t/:slug" element={<ResumeTreatment />} />
16
+ </Routes>
17
+ </ResumeProvider>
18
+ ```
19
+
20
+ ```js
21
+ // vite.config.js
22
+ import { kitExportPlugin } from '@kolkrabbi/kol-resume/vite'
23
+ plugins: [react(), tailwindcss(), kitExportPlugin({ name: 'ada-lovelace' })]
24
+ ```
25
+
26
+ ```css
27
+ @import "@kolkrabbi/kol-resume/kol-resume.css"; /* the print rules, after kol-theme */
28
+ ```
29
+
30
+ The host checklist and the provider's props: ui.kolkrabbi.io, Compositions › Resume.
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Export the job-application kit to PDF — one combined file per theme.
5
+ #
6
+ # Builds the app and serves the static output with `vite preview` on a
7
+ # DEDICATED port (not the user's 5173), renders each route with headless
8
+ # Chrome, and merges them in kit order (Cover → Résumé → Letter → Portfolio →
9
+ # Back) with pdfunite. Kills only its own server on exit; never touches a live
10
+ # dev session. A production build (not the dev server) is what keeps Chrome's
11
+ # network-idle from hanging — see the preview note below.
12
+ #
13
+ # Rendering goes through puppeteer-core (app/scripts/render-pdf.mjs), which waits
14
+ # for fonts + images before printing — see that file. Never reintroduce the
15
+ # legacy --virtual-time-budget flag; it hangs forever under --headless=new.
16
+ #
17
+ # Usage: scripts/export-kit.sh [theme ...] (default: light dark)
18
+ # scripts/export-kit.sh --v2 (light only, no background
19
+ # colors, downscaled images →
20
+ # out/$NAME-kit-light-v2.pdf)
21
+ #
22
+ # Flags (combinable with explicit themes):
23
+ # --plain render with ?plain=1 — strips every background fill so the kit
24
+ # prints ink-on-white (light theme only; dark text needs a light bg)
25
+ # --downscale shrink the printed images (portrait + portfolio heroes) in the
26
+ # BUILT dist/ before previewing — originals in public/ are untouched
27
+ # --icelandic render every route with ?lang=is (Icelandic kit), output suffix
28
+ # -icelandic
29
+ # --svg export each section as a self-contained SVG with editable <text>
30
+ # (out/svg/*.svg) + a vector PDF merged from them — for Illustrator
31
+ # --v2 shorthand: light only + --plain + --downscale, output suffix -v2
32
+ # --continuous fit-to-length PDF from /resume/t/kit-continuous — one page per
33
+ # section at its natural height (not A4-paginated) → out/…-continuous.pdf
34
+ #
35
+ # Env: KIT_QUERY — extra URL query appended to every rendered route, e.g.
36
+ # "&pages=cover,resume&role=Art%20Director&company=Acme". Set by the
37
+ # /api/export endpoint from the preview's own options overlay; it narrows
38
+ # which kit sections compile and fills the cover letter's Role/Company.
39
+
40
+ # CARRIED INTO @kolkrabbi/kol-resume (ResumeKit, 2026-10-28): the app is the caller's working directory (the vite
41
+ # plugin spawns this with cwd = the app root), render-pdf.mjs is the package's own, and the file stem is $KIT_NAME —
42
+ # nobody's name is baked in.
43
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
44
+ ROOT="${KIT_APP:-$PWD}"
45
+ APP="$ROOT"
46
+ NAME="${KIT_NAME:-kit}"
47
+ OUT="$ROOT/out"
48
+ PORT=5180
49
+ BASE="http://localhost:$PORT"
50
+ CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
51
+
52
+ PLAIN=0; DOWNSCALE=0; SVG=0; CONTINUOUS=0; SINGLE=0; ROUTE_SINGLE=""; NAME_SINGLE=""; SUFFIX=""; KIT_LANG="en"; THEME_ARGS=()
53
+ for a in "$@"; do
54
+ case "$a" in
55
+ --v2) PLAIN=1; DOWNSCALE=1; SUFFIX="-v2"; THEME_ARGS=(light) ;;
56
+ --continuous) CONTINUOUS=1; PLAIN=1; DOWNSCALE=1; THEME_ARGS=(light) ;; # fit-to-length PDF from /resume/t/kit-continuous (light plain, like --v2)
57
+ --single=*) SINGLE=1; PLAIN=1; THEME_ARGS=(light) # one route → one ink-on-white A4 PDF (the layout treatments' download buttons)
58
+ rest="${a#--single=}"; ROUTE_SINGLE="${rest%%:*}"; NAME_SINGLE="${rest#*:}" ;;
59
+ --plain) PLAIN=1 ;;
60
+ --downscale) DOWNSCALE=1 ;;
61
+ --icelandic) KIT_LANG="is" ;; # render every route with ?lang=is
62
+ --svg) SVG=1; DOWNSCALE=1 ;; # editable-text SVG per section (implies downscale — images inline as data URIs)
63
+ light|dark) THEME_ARGS+=("$a") ;;
64
+ *) echo "unknown arg: $a" >&2; exit 2 ;;
65
+ esac
66
+ done
67
+ if [ "${#THEME_ARGS[@]}" -gt 0 ]; then THEMES=("${THEME_ARGS[@]}"); else THEMES=(light dark); fi
68
+ QUERY=""; [ "$PLAIN" -eq 1 ] && QUERY="&plain=1"
69
+ [ "$KIT_LANG" = "is" ] && { QUERY="$QUERY&lang=is"; LANGSUF="-icelandic"; } || LANGSUF=""
70
+ # Kit options from the caller (vite's /api/export sets this) — already a
71
+ # URL-encoded "&pages=…&role=…&company=…" fragment. Env, so it never hits the shell.
72
+ QUERY="$QUERY${KIT_QUERY:-}"
73
+ DOWNSCALE_MAX=1400 # longest side; heroes show thumbnail-size, portrait smaller
74
+
75
+ # Render one URL to PDF through puppeteer-core (CDP) — app/scripts/render-pdf.mjs.
76
+ # It waits for document.fonts.ready + image decode before the snapshot, so the
77
+ # PDF can't bake a fallback font or drop an image, then closes Chrome itself.
78
+ # (Replaces a bare `chrome --print-to-pdf`, which raced the fonts and lingered.)
79
+ print_pdf() { # $1 (unused) $2 out.pdf $3 url
80
+ local out="$2" url="$3"
81
+ rm -f "$out"
82
+ CHROME="$CHROME" node "$HERE/render-pdf.mjs" "$url" "$out" >>"$TMP/render.log" 2>&1
83
+ [ -s "$out" ]
84
+ }
85
+
86
+ # The whole kit is ONE route — the /resume/t/kit-a4 treatment the user actually
87
+ # sees, where A4Page lays each section onto real A4 sheets with proper margins.
88
+ # (The old per-section routes printed the wide screen layout, so the portfolio
89
+ # overflowed the page.) Language rides $QUERY (&lang=is). label = temp filename.
90
+ ROUTES=( "kit:/resume/t/kit-a4" )
91
+
92
+ mkdir -p "$OUT"
93
+ TMP="$(mktemp -d)"
94
+
95
+ echo "▸ building app (production)"
96
+ ( cd "$APP" && VITE_BUCKET_LOCAL=1 node_modules/.bin/vite build >"$TMP/build.log" 2>&1 ) \
97
+ || { echo "build failed:"; cat "$TMP/build.log"; exit 1; }
98
+
99
+ # The images the kit prints, fetched into the build's own dist/bucket/ (VITE_BUCKET_LOCAL=1 points the app there): the
100
+ # buckets are the only copy (2026-10-28), and a remote image is not reliably decoded before Chrome prints. The cover
101
+ # photo comes from the private bucket through wrangler's login, the portfolio heroes from the public R2.
102
+ # THE IMAGES ARE THE APP'S (ResumeKit, 2026-10-28): record fetched its cover from a private bucket through wrangler and
103
+ # its heroes from R2 here. In the package that is the app's own step — `scripts/fetch-kit-images.sh`, run with APP and
104
+ # TMP set, if the app has one; record's block is that file now (its RecordOnKolResume ticket).
105
+ if [ -x "$APP/scripts/fetch-kit-images.sh" ]; then
106
+ echo "▸ fetching kit images ($APP/scripts/fetch-kit-images.sh)"
107
+ APP="$APP" TMP="$TMP" "$APP/scripts/fetch-kit-images.sh" || { echo "image fetch failed"; exit 1; }
108
+ fi
109
+
110
+ # Downscale the images that actually print (cover photo + portfolio heroes) inside
111
+ # the built dist/ — fetched above; the buckets keep the full-size originals. Heroes render at thumbnail size in the grid and
112
+ # the portrait small, so a 1400px cap is invisible but slashes the PDF weight.
113
+ if [ "$DOWNSCALE" -eq 1 ]; then
114
+ echo "▸ downscaling kit images (cap ${DOWNSCALE_MAX}px)"
115
+ shopt -s nullglob
116
+ # every image the app's fetch step put under dist/bucket (record's cover and heroes were named here; $COVER went with
117
+ # its fetch block)
118
+ { find "$APP/dist/bucket" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \) 2>/dev/null || true; } | while IFS= read -r f; do
119
+ [ -f "$f" ] || continue
120
+ before=$(stat -f%z "$f" 2>/dev/null || echo 0)
121
+ # Downscale ONLY — sips -Z upsamples images already under the cap (softens
122
+ # the portrait, bloats the file), so resize only when the long side exceeds it.
123
+ w=$(sips -g pixelWidth "$f" 2>/dev/null | awk '/pixelWidth/{print $2}')
124
+ h=$(sips -g pixelHeight "$f" 2>/dev/null | awk '/pixelHeight/{print $2}')
125
+ long=$(( w > h ? w : h ))
126
+ [ "$long" -gt "$DOWNSCALE_MAX" ] && sips -Z "$DOWNSCALE_MAX" "$f" >/dev/null 2>&1
127
+ case "$f" in *.jpg|*.jpeg) sips -s formatOptions 78 "$f" >/dev/null 2>&1 || true ;; esac
128
+ after=$(stat -f%z "$f" 2>/dev/null || echo 0)
129
+ echo " · ${f#"$APP"/dist/} ${long}px $((before/1024))K → $((after/1024))K"
130
+ done
131
+ shopt -u nullglob
132
+ fi
133
+
134
+ # Serve the STATIC build, not the dev server: vite preview has no HMR websocket
135
+ # and streams no module requests, so headless Chrome's "network idle" fires
136
+ # immediately. The dev server never reaches idle on heavier routes → Chrome hangs.
137
+ echo "▸ starting preview server on :$PORT"
138
+ ( cd "$APP" && exec node_modules/.bin/vite preview --port "$PORT" --strictPort >"$TMP/serve.log" 2>&1 ) &
139
+ VITE_PID=$!
140
+ trap 'kill "$VITE_PID" 2>/dev/null; rm -rf "$TMP"' EXIT
141
+
142
+ for i in $(seq 1 80); do
143
+ curl -sf "$BASE/" >/dev/null 2>&1 && break
144
+ if ! kill -0 "$VITE_PID" 2>/dev/null; then echo "preview died:"; cat "$TMP/serve.log"; exit 1; fi
145
+ sleep 0.5
146
+ done
147
+
148
+ # Single layout treatment → one ink-on-white A4 PDF (the /resume/t/<slug> download
149
+ # buttons hit this via /api/export?layout=<slug>). Renders that one route only.
150
+ if [ "$SINGLE" -eq 1 ]; then
151
+ echo "▸ rendering single route $ROUTE_SINGLE"
152
+ out="$OUT/$NAME_SINGLE.pdf"
153
+ print_pdf "$TMP/chrome-single" "$out" "$BASE$ROUTE_SINGLE?theme=light$QUERY" \
154
+ || { echo "single render failed:"; cat "$TMP/render.log"; exit 1; }
155
+ echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
156
+ echo "✓ done"
157
+ exit 0
158
+ fi
159
+
160
+ # SVG export — one self-contained SVG per kit section (continuous mode), with
161
+ # live <text> + embedded fonts + inlined images: editable type in Illustrator.
162
+ # No rsvg→PDF here: librsvg ignores the embedded webfonts (falls back to system
163
+ # fonts), so that PDF would be font-unfaithful — the faithful flat PDF is the
164
+ # default (non-SVG) export. SVGs are the editable deliverable.
165
+ if [ "$SVG" -eq 1 ]; then
166
+ echo "▸ rendering SVG (editable text)"
167
+ SVGROUTE="/resume/t/kit-continuous?theme=light$QUERY"
168
+ SVGDIR="$OUT/svg"; rm -rf "$SVGDIR"
169
+ CHROME="$CHROME" node "$APP/scripts/render-svg.mjs" "$BASE" "$SVGROUTE" "$SVGDIR"
170
+ echo " → $SVGDIR/*.svg (open in Illustrator)"
171
+ echo "✓ done"
172
+ exit 0
173
+ fi
174
+
175
+ # Continuous PDF — one page per section, each sized to fit its own content length
176
+ # (the /resume/t/kit-continuous look), instead of the A4-paginated employer PDF.
177
+ # render-pdf.mjs writes a numbered PDF per section; pdfunite merges them in order.
178
+ if [ "$CONTINUOUS" -eq 1 ]; then
179
+ echo "▸ rendering continuous PDF (fit-to-length pages)"
180
+ CDIR="$TMP/continuous"; rm -rf "$CDIR"
181
+ CHROME="$CHROME" node "$HERE/render-pdf.mjs" "$BASE/resume/t/kit-continuous?theme=light$QUERY" "$CDIR" continuous >>"$TMP/render.log" 2>&1 \
182
+ || { echo "continuous render failed:"; cat "$TMP/render.log"; exit 1; }
183
+ out="$OUT/$NAME-kit-continuous$LANGSUF.pdf"
184
+ pdfunite "$CDIR"/*.pdf "$out"
185
+ echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
186
+ echo "✓ done"
187
+ exit 0
188
+ fi
189
+
190
+ for theme in "${THEMES[@]}"; do
191
+ echo "▸ rendering $theme"
192
+ parts=()
193
+ for entry in "${ROUTES[@]}"; do
194
+ label="${entry%%:*}"; path="${entry#*:}"
195
+ pdf="$TMP/$theme-$label.pdf"
196
+ if print_pdf "$TMP/chrome-$theme-$label" "$pdf" "$BASE$path?theme=$theme$QUERY"; then
197
+ echo " · $label"
198
+ parts+=("$pdf")
199
+ else
200
+ echo " · $label ⚠ render failed — skipped (see $TMP/render.log)"
201
+ fi
202
+ done
203
+ out="$OUT/$NAME-kit-$theme$SUFFIX$LANGSUF.pdf"
204
+ pdfunite "${parts[@]}" "$out"
205
+ echo " → $out ($(du -h "$out" | cut -f1 | tr -d ' '), $(qpdf --show-npages "$out") pp)"
206
+ done
207
+
208
+ echo "✓ done"
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Render one URL to a PDF via the installed Chrome (puppeteer-core / CDP).
4
+ *
5
+ * The point of going through CDP instead of `chrome --print-to-pdf`: we can wait
6
+ * for document.fonts.ready and image decode BEFORE the print snapshot, so the PDF
7
+ * never bakes a fallback font (the Right Grotesk cuts use font-display:swap and
8
+ * lose the load race under a bare --print-to-pdf) or drops a half-loaded image.
9
+ *
10
+ * Two modes:
11
+ * a4 (default) — honors the page's own @page CSS (A4 portrait, margin:0 — the
12
+ * kit sets its own inner sheet margins) and prints backgrounds.
13
+ * <out> is the PDF file.
14
+ * continuous — one page per .kol-kit-page sheet, each sized to fit its own
15
+ * content length (no A4 pagination). Renders the SCREEN layout
16
+ * the /resume/t/kit-continuous treatment shows, isolating one sheet at a
17
+ * time. <out> is a DIRECTORY of numbered per-section PDFs (merge
18
+ * with pdfunite downstream, like render-svg.mjs).
19
+ *
20
+ * Usage: node render-pdf.mjs <url> <out> [a4|continuous]
21
+ */
22
+ import puppeteer from 'puppeteer-core'
23
+ import { mkdirSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+
26
+ const [url, out, mode = 'a4'] = process.argv.slice(2)
27
+ const CHROME = process.env.CHROME || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
28
+
29
+ if (!url || !out) {
30
+ console.error('usage: render-pdf.mjs <url> <out> [a4|continuous]')
31
+ process.exit(2)
32
+ }
33
+
34
+ const browser = await puppeteer.launch({
35
+ executablePath: CHROME,
36
+ headless: true,
37
+ args: ['--hide-scrollbars', '--disable-gpu'],
38
+ })
39
+ try {
40
+ const page = await browser.newPage()
41
+ await page.setViewport({ width: 1200, height: 1600 })
42
+ await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 })
43
+ // Block on the things --print-to-pdf won't wait for: web fonts and image bytes.
44
+ await page.evaluate(async () => {
45
+ await document.fonts.ready
46
+ await Promise.all([...document.images].map((img) => (img.complete ? null : img.decode().catch(() => {}))))
47
+ })
48
+
49
+ if (mode === 'continuous') {
50
+ // Match the on-screen continuous preview (not print media, whose @page would
51
+ // force uniform A4 pages). Screen media means the app shell renders too, so
52
+ // replicate what kol-print.css does: hide the sidenav chrome + the download
53
+ // toolbar, and collapse the shell grid to one full-width column (else its
54
+ // sidenav gutter column offsets the sheet). Then measure every sheet.
55
+ const rects = await page.emulateMediaType('screen').then(() =>
56
+ page.evaluate(() => {
57
+ document
58
+ .querySelectorAll('.kol-sidenav, .kol-sidenav-hamburger, .kol-sidenav-backdrop, .kol-kit-preview .print\\:hidden')
59
+ .forEach((el) => (el.style.display = 'none'))
60
+ const layout = document.querySelector('.kol-brand-layout')
61
+ if (layout) layout.style.display = 'block'
62
+ const prev = document.querySelector('.kol-kit-preview')
63
+ Object.assign(prev.style, { background: '#fff', padding: '0', gap: '0', minHeight: '0' })
64
+ return [...document.querySelectorAll('.kol-kit-page')].map((el) => {
65
+ const r = el.getBoundingClientRect()
66
+ return { w: Math.ceil(r.width), h: Math.ceil(r.height), label: el.getAttribute('aria-label') || 'page' }
67
+ })
68
+ }),
69
+ )
70
+ if (!rects.length) { console.error('no .kol-kit-page sections found'); process.exit(1) }
71
+ mkdirSync(out, { recursive: true })
72
+ for (let i = 0; i < rects.length; i++) {
73
+ // Show only sheet i; page.pdf sized to it → exactly one fit-to-length page.
74
+ await page.evaluate((idx) => {
75
+ document.querySelectorAll('.kol-kit-page').forEach((el, k) => { el.style.display = k === idx ? '' : 'none' })
76
+ }, i)
77
+ const { w, h, label } = rects[i]
78
+ const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
79
+ const name = `${String(i + 1).padStart(2, '0')}-${slug}.pdf`
80
+ // pageRanges:'1' guards against a phantom trailing page from sub-px rounding
81
+ // (h is ceil'd, so page 1 always contains the whole sheet).
82
+ await page.pdf({ path: join(out, name), printBackground: true, width: `${w}px`, height: `${h}px`, pageRanges: '1' })
83
+ console.log(` · ${name} (${w}×${h}px)`)
84
+ }
85
+ } else {
86
+ await page.pdf({ path: out, printBackground: true, preferCSSPageSize: true })
87
+ }
88
+ } finally {
89
+ await browser.close()
90
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-resume",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The resume kit: a resume in three treatments, cover, letter, portfolio and back pages, the A4 kit and its PDF export",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "module": "./src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./vite": "./src/vite.js",
12
+ "./kol-resume.css": "./src/kol-resume.css",
13
+ "./styles/*": "./src/styles/*"
14
+ },
15
+ "bin": {
16
+ "kol-resume-pdf": "./bin/render-pdf.mjs"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "README.md"
22
+ ],
23
+ "dependencies": {
24
+ "yaml": "^2.9.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@kolkrabbi/kol-component": ">=0.300.0",
28
+ "@kolkrabbi/kol-styleguide": ">=0.14.0",
29
+ "@kolkrabbi/kol-theme": ">=0.196.0",
30
+ "puppeteer-core": ">=22.0.0",
31
+ "react": "^18.3.0 || ^19.0.0",
32
+ "react-dom": "^18.3.0 || ^19.0.0",
33
+ "react-router-dom": ">=6.20.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "puppeteer-core": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@kolkrabbi/kol-component": "^0.311.0",
42
+ "@kolkrabbi/kol-styleguide": "^0.14.0",
43
+ "@kolkrabbi/kol-theme": "^0.196.0"
44
+ },
45
+ "keywords": [
46
+ "kol",
47
+ "kolkrabbi",
48
+ "design-system",
49
+ "resume",
50
+ "cv",
51
+ "print",
52
+ "a4"
53
+ ],
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
57
+ "directory": "packages/resume"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ }
6
62
  }
@@ -0,0 +1,34 @@
1
+ import { useResume } from '../data.jsx'
2
+
3
+ /**
4
+ * The kit's contact footer — the identical strip that closes both the Cover and
5
+ * the Back page. Every entry is a real link: Chrome's headless printToPDF keeps
6
+ * <a href> as a clickable annotation, so the exported PDF is live off the page.
7
+ *
8
+ * Display text is the data string verbatim (example.com, linkedin.com/name);
9
+ * only the href is synthesised, so the printed line reads the same as before.
10
+ */
11
+ /* the contact is the person's (the seam): the entries are built from the provider's BRAND_INFO inside the component */
12
+ const entriesOf = ({ contact = {}, social = {} } = {}) => [
13
+ [contact.emailJobs, `mailto:${contact.emailJobs}`],
14
+ [contact.phone, `tel:${String(contact.phone ?? '').replace(/\s/g, '')}`],
15
+ [contact.web, `https://${contact.web}`],
16
+ [social.linkedin, `https://${social.linkedin}`],
17
+ [social.github, `https://${social.github}`],
18
+ ].filter(([label]) => label)
19
+
20
+ export default function ContactLine({ className = '' }) {
21
+ const ENTRIES = entriesOf(useResume().BRAND_INFO)
22
+ return (
23
+ <p className={`kol-mono-10 text-meta mt-auto pt-6 border-t border-fg-08 ${className}`}>
24
+ {ENTRIES.map(([label, href], i) => (
25
+ <span key={label}>
26
+ {i > 0 && ' · '}
27
+ <a href={href} target="_blank" rel="noreferrer" className="no-underline text-inherit">
28
+ {label}
29
+ </a>
30
+ </span>
31
+ ))}
32
+ </p>
33
+ )
34
+ }
@@ -0,0 +1,41 @@
1
+ import { useExportDownload } from '../lib/exportDownload'
2
+ import { exportRequest } from '../lib/treatments'
3
+ import { useResume } from '../data.jsx'
4
+
5
+ /**
6
+ * Fixed top-right download controls for a treatment preview. Kit treatments
7
+ * offer the flat PDF and the editable-text SVG zip; layouts the PDF only. Both
8
+ * go through /api/export (~30–45s: build + headless render), so the running
9
+ * button shows progress. `print:hidden` keeps the chrome out of the file.
10
+ */
11
+ export default function ExportButtons({ treatment, lang = 'en', options }) {
12
+ const { busy, err, download } = useExportDownload()
13
+ const { FILE_STEM } = useResume()
14
+ const run = (format) => {
15
+ const { url, filename } = exportRequest(treatment, { lang, format, options, name: FILE_STEM })
16
+ download(format, url, filename)
17
+ }
18
+
19
+ const btn =
20
+ 'kol-mono-12 px-3 py-1.5 rounded bg-[#121215] text-white cursor-pointer transition-colors disabled:opacity-60 disabled:cursor-wait'
21
+
22
+ return (
23
+ <div className="fixed top-4 right-4 z-50 print:hidden flex flex-col items-end gap-2">
24
+ <div className="flex gap-2">
25
+ <button type="button" disabled={!!busy} onClick={() => run('pdf')} className={btn}>
26
+ {busy === 'pdf' ? 'Generating…' : 'Download PDF'}
27
+ </button>
28
+ {treatment.kind === 'kit' && (
29
+ <button type="button" disabled={!!busy} onClick={() => run('svg')} className={btn}>
30
+ {busy === 'svg' ? 'Generating…' : 'SVG (.zip)'}
31
+ </button>
32
+ )}
33
+ </div>
34
+ {err && (
35
+ <pre className="kol-mono-10 max-w-xs whitespace-pre-wrap text-right text-red-700 bg-white/95 border border-red-200 rounded p-2">
36
+ {err}
37
+ </pre>
38
+ )}
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,98 @@
1
+ import { useState } from 'react'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { KIT_PAGES, KIT_PAGE_IDS, readKitOptions } from '../lib/kitOptions'
4
+ import LangToggle from './LangToggle'
5
+
6
+ /**
7
+ * The export options — language, which numbered sections compile into the
8
+ * kit, and the Role / Company the cover letter is addressed to.
9
+ *
10
+ * Everything writes straight to the URL query (lib/kitOptions.js, `?lang`):
11
+ * the preview re-renders live, the Library's Open links carry the same query,
12
+ * and the headless export reloads the very same URL — so what is set here is
13
+ * exactly what compiles. `kit` = false (a layout treatment) shows language only.
14
+ *
15
+ * Two shells: `KitOptionsFields` is the bare form (the Library renders it
16
+ * inline); the default export floats it as a light-scoped overlay over a
17
+ * treatment preview.
18
+ */
19
+ export function KitOptionsFields({ kit = true }) {
20
+ const [params, setParams] = useSearchParams()
21
+ const { pages, role, company } = readKitOptions(params)
22
+
23
+ // Always write every field, so a cleared input actually clears in the URL.
24
+ const set = (next) => {
25
+ const p = new URLSearchParams(params)
26
+ const v = { pages, role, company, ...next }
27
+ v.pages.length === KIT_PAGE_IDS.length ? p.delete('pages') : p.set('pages', v.pages.join(','))
28
+ v.role ? p.set('role', v.role) : p.delete('role')
29
+ v.company ? p.set('company', v.company) : p.delete('company')
30
+ setParams(p, { replace: true })
31
+ }
32
+
33
+ const togglePage = (id) =>
34
+ set({ pages: KIT_PAGE_IDS.filter((p) => (p === id ? !pages.includes(id) : pages.includes(p))) })
35
+
36
+ const head = 'kol-helper-10 text-meta uppercase'
37
+ const field =
38
+ 'kol-mono-12 w-full bg-surface-primary text-emphasis border border-fg-16 rounded px-2 py-1 outline-none focus:border-fg-64'
39
+
40
+ return (
41
+ <div className="flex flex-col gap-4">
42
+ <div className="flex flex-col gap-1.5">
43
+ <p className={head}>Language</p>
44
+ <LangToggle className="max-w-[8rem]" />
45
+ </div>
46
+
47
+ {kit && (
48
+ <>
49
+ <div className="flex flex-col gap-1.5">
50
+ <p className={head}>Pages in PDF</p>
51
+ {KIT_PAGES.map((p, i) => (
52
+ <label key={p.id} className="kol-mono-12 flex items-center gap-2 text-emphasis cursor-pointer">
53
+ <input
54
+ type="checkbox"
55
+ checked={pages.includes(p.id)}
56
+ onChange={() => togglePage(p.id)}
57
+ className="accent-current"
58
+ />
59
+ <span className="text-subtle">{String(i + 1).padStart(2, '0')}</span>
60
+ {p.label}
61
+ </label>
62
+ ))}
63
+ </div>
64
+
65
+ <div className="flex flex-col gap-1.5">
66
+ <p className={head}>Cover letter</p>
67
+ <input className={field} placeholder="Role" value={role} onChange={(e) => set({ role: e.target.value })} />
68
+ <input className={field} placeholder="Company" value={company} onChange={(e) => set({ company: e.target.value })} />
69
+ </div>
70
+ </>
71
+ )}
72
+ </div>
73
+ )
74
+ }
75
+
76
+ export default function KitOptions({ kit = true }) {
77
+ const [params] = useSearchParams()
78
+ const { pages } = readKitOptions(params)
79
+ const [open, setOpen] = useState(false)
80
+
81
+ return (
82
+ <div className="fixed top-16 right-4 z-50 print:hidden flex flex-col items-end gap-2" data-theme="light">
83
+ <button
84
+ type="button"
85
+ onClick={() => setOpen(!open)}
86
+ className="kol-mono-12 px-3 py-1.5 rounded bg-[#121215] text-white cursor-pointer"
87
+ >
88
+ {open ? 'Close options' : kit ? `Options · ${pages.length}/${KIT_PAGE_IDS.length}` : 'Options'}
89
+ </button>
90
+
91
+ {open && (
92
+ <div className="w-64 bg-surface-tertiary border border-fg-08 rounded p-3 shadow-lg">
93
+ <KitOptionsFields kit={kit} />
94
+ </div>
95
+ )}
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,27 @@
1
+ import { useSearchParams } from 'react-router-dom'
2
+ import { SegmentedToggle } from '@kolkrabbi/kol-component'
3
+ import { readLang } from '../lib/i18n'
4
+
5
+ /** EN / IS, written to `?lang` — the one value every resume surface (source,
6
+ * library, treatment preview, headless export) reads. English clears the param. */
7
+ export default function LangToggle({ className = '' }) {
8
+ const [params, setParams] = useSearchParams()
9
+ const set = (v) => {
10
+ const p = new URLSearchParams(params)
11
+ v === 'is' ? p.set('lang', 'is') : p.delete('lang')
12
+ setParams(p, { replace: true })
13
+ }
14
+ return (
15
+ <SegmentedToggle
16
+ value={readLang(params)}
17
+ onChange={set}
18
+ size="sm"
19
+ ariaLabel="Language"
20
+ options={[
21
+ { value: 'en', label: 'EN' },
22
+ { value: 'is', label: 'IS' },
23
+ ]}
24
+ className={className}
25
+ />
26
+ )
27
+ }
package/src/data.jsx ADDED
@@ -0,0 +1,60 @@
1
+ import { createContext, useContext, useMemo } from 'react'
2
+ import { sortedWork, cvEducation, contactLines } from './lib/resume.js'
3
+ import { parseLetters } from './lib/letters.js'
4
+ import { parsePortfolio } from './lib/portfolio.js'
5
+
6
+ /**
7
+ * ResumeProvider — the kit's ONE SEAM (ResumeKit, kol-website 2026-10-28: *"Nobody's details ship in the package"*). Every
8
+ * page, component and selector read `@data/business-data.js`, `@data/info.js`, `@data/records`, `data/letters/*.md`,
9
+ * `data/portfolio/*.md` and a bucket helper; they read this instead, under the same names, so the carried code is
10
+ * unchanged but for one `useResume()` line.
11
+ *
12
+ * person `{ BIO, SKILLS, LANGUAGES }` — the shape of record's `data/business-data.js` (bio with `_is` twins,
13
+ * skill groups, languages)
14
+ * records `{ WORK, EDUCATION }` — record's `data/records`
15
+ * contact record's `data/info.js` `BRAND_INFO` — `{ contact: { phone, emailJobs, web }, social: { linkedin, github } }`
16
+ * letters `{ [path]: raw markdown }` — the app's glob of its cover letters (`NN-<id>.md`, frontmatter + paragraphs)
17
+ * portfolio `{ [path]: raw markdown }` — the app's glob of its projects
18
+ * media the portfolio's downloaded shots by project id (record's `portfolio-manifest.json`)
19
+ * workBase where those shots load from (`<workBase>/<id>/hero.jpg`)
20
+ * workUrl `(id) => url` — a project's public case study, when its frontmatter has no `work:`
21
+ * assetUrl `(key) => url` — an image by bucket key (the cover's portrait)
22
+ * portrait the cover portrait's key (record's `portraits/portrait-bw-street-sunglasses-02.jpg`)
23
+ * copy the page words, per page and language: `{ back: { en: { kicker, title, blurb, current }, is }, portfolio:
24
+ * { en: { kicker, title, intro }, is } }` — each key overrides the kit's default
25
+ * fileStem the exports' file stem (`<stem>-kit.pdf`); defaults to the person's name, slugged
26
+ */
27
+ const ResumeContext = createContext(null)
28
+
29
+ export function ResumeProvider({ person = {}, records = {}, contact = {}, letters = {}, portfolio = {}, media = {}, workBase = '', workUrl, assetUrl = (k) => k, portrait, fileStem, copy = {}, children }) {
30
+ const value = useMemo(() => {
31
+ const { BIO = {}, SKILLS = [], LANGUAGES = [] } = person
32
+ const { WORK = [], EDUCATION = [] } = records
33
+ const LETTERS = parseLetters(letters)
34
+ const PROJECTS = parsePortfolio(portfolio, { media, workBase, workUrl })
35
+ return {
36
+ BIO, SKILLS, LANGUAGES, WORK, EDUCATION, BRAND_INFO: contact,
37
+ LETTERS,
38
+ DEFAULT_LETTER: LETTERS[0],
39
+ getLetter: (id) => LETTERS.find((l) => l.id === id) ?? LETTERS[0],
40
+ PROJECTS,
41
+ getProject: (id) => PROJECTS.find((p) => p.id === id),
42
+ getMedia: (id) => media[id] ?? [],
43
+ PORTFOLIO_BASE: workBase,
44
+ bucketUrl: assetUrl,
45
+ PORTRAIT: portrait,
46
+ COPY: copy,
47
+ FILE_STEM: fileStem ?? (String(BIO.displayName ?? 'kit').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'kit'),
48
+ sortedWork: () => sortedWork(WORK),
49
+ cvEducation: () => cvEducation(EDUCATION),
50
+ contactLines: () => contactLines(BIO, contact),
51
+ }
52
+ }, [person, records, contact, letters, portfolio, media, workBase, workUrl, assetUrl, portrait, fileStem, copy])
53
+ return <ResumeContext.Provider value={value}>{children}</ResumeContext.Provider>
54
+ }
55
+
56
+ export function useResume() {
57
+ const v = useContext(ResumeContext)
58
+ if (!v) throw new Error('kol-resume: wrap the kit in <ResumeProvider> — its pages read the person from it')
59
+ return v
60
+ }