quikResume showcase only

One library · many résumés · Typst in the browser

The same career, cut two ways.

quikResume keeps your whole professional history in one library and rebuilds the résumé from whatever is switched on. Both sheets below came out of the same 22 items, minutes apart, typeset by Typst compiled to WebAssembly inside the browser — the résumé text never reaches a rendering server.

A Everything switched on
Alex Morgan's full résumé compiled by Typst, running to two pages
2 pages 20 of 22 items fit 84 / 100
B Tailored to one job, trimmed to one page
The same résumé tailored and trimmed to a single page
1 page 11 of 22 items fit 79 / 100

Click either sheet to enlarge. Both are real compiler output, not mock-ups.


The example user

Everything here belongs to a candidate who does not exist.

Alex Morgan is a fixture. The screenshots were taken against a throwaway account in a local Firestore emulator, so no real person's history appears anywhere on this page — but every number the app computes from that library is genuine.

Alex Morgan

B.S. Computer Science, Class of 2026 · Seattle, WA · targeting new-grad backend and platform roles

Two production internships — Go tooling and a 40-service secrets migration at Northwind Cloud, five FastAPI microservices and Kubernetes CronJobs at Contoso Health — plus a systems-lab stint that produced a USENIX ATC paper, three projects, an AWS certificate, a hackathon win, a mentoring role, and a coffee-shop job from before university that is switched off for engineering applications.

Experience
4
Education
1
Skill categories
6
Projects
3
Certifications
3
Awards · volunteering
1 · 1
Publications · languages
1 · 2
Library items
22
Extracted tags
80
Saved variants
3

The tour

Nine features, each with the mechanism behind it.

Every panel states what you get in plain language, shows the real screen, and then opens up the actual implementation — file paths included.

Nothing is ever retyped. Flip switches per application: this internship on, the coffee-shop job off, these three bullets yes, the fourth no.

The dimmed Barista row stays in the library for retail applications but will not print here. The yellow card at the top is the app pointing out that this one item is dragging the résumé down.

How it works Three levels of granularity
item.isSelected   // whole item, the row switch
item.hidden[]     // one bullet  → bulletKey(text)
item.hidden[]     // one skill   → skillKey(text)

bulletKey is the whitespace-squashed text of the printed line, never an index (src/lib/sub-items.ts). Reordering bullets therefore can never hide the wrong one, and rewording a hidden line brings it back on purpose. saveResumeData runs pruneHidden(...) so keys whose text is gone are dropped rather than left to rot.

Toggling costs nothing: hidden and isSelected are excluded from the content hash, so switching items on and off never re-triggers the AI tagger.

The library: sections with counts, coach score badges, date ranges and include switches

/dashboard · 22 items across 9 sections

What you see is the PDF. Not an HTML lookalike — the actual Typst document, recompiled about a third of a second after you stop typing, with a text-based download an applicant tracking system can parse.

The page count in the toolbar is measured from the compiled document, not estimated — and it is the number the Tailor window works against.

How it works One JSON contract, one wasm engine
ResumeData
  → toTypstDoc()        // the ONLY place data is shaped
  → TypstResumeDoc      // plain JSON, every field a string
  → sys.inputs.resume   // main.typ
  → templates/ledger.typ  render(data)
  → typst-ts-web-compiler (~27 MB wasm)
  → compileSvg() preview | compilePdf() download

Templates are pure styling: no filtering, no date logic, and they never receive markup. Because every field arrives as a plain string, user text containing # * _ $ [ ] \ needs no escaping — it is printed literally.

The engine initialises once, lazily, when the preview first mounts; the vendored Inter faces are preloaded and remote font assets disabled, so output is byte-stable across machines. The wasm is single-threaded, so compiles are serialised through one promise chain, and the last good SVG stays on screen at 60% opacity while a new one renders.

If the engine fails to load, the app says so in its own panel — and states explicitly that nothing in your résumé was filtered or rejected.

The library with the Typst preview docked as a resizable side pane

The preview docks beside the library at ≥ 1280 px · resizable splitter

Paste the posting, get an honest number. Which must-haves you cover, which single one disqualifies you, which keywords are missing — and separately, whether the words are literally printed on the page a scanner will read.

Alex scores 84. The blocker is C++, called out in red instead of averaged away. Four rows say Not literally: Alex has the skill, but the exact string is not on the page.

How it works Coverage, inference, and the ATS check

An item covers a requirement when it carries the exact canonical tag key, a key the requirement's satisfiedBy list accepts, a key the built-in hierarchy accepts (bachelor's degree ← any bachelor of …), or is cited by item id in evidence.

The last two come from the reconcile pass (src/lib/match/reconcile.ts): the text model receives all requirements plus the whole tag inventory at max reasoning effort, and its verdict is stored on the requirement — so the pure scorer honours it on every later recompute with no further model calls. It runs in the background; if it fails, requirements stay untouched and the built-in hierarchy still applies.

That is how Alex's résumé matches Microservices without ever using the word: the pass read "five FastAPI microservices behind an API gateway" and recorded the evidence.

literalHit searches renderedText(toTypstDoc(data)) — the exact strings Typst prints, with hidden bullets and excluded items removed. Hence the honest split between "in your tags" and "printed on résumé".

Job Match: score 84, guidelines, and the ATS keyword check table

?view=jobs · 14 requirements extracted from the posting

It suggests; you decide. Tailor asks about the gaps first, proposes a selection with a reason on every line, marks what it will not touch, and leaves each suggestion as an Accept or Dismiss you can overrule.

🔒 marks items that cover a hard requirement. One click on Fit to one page took Alex from 20 items on two pages to 11 on one — and told him the price: 84 → 79, with no must-have lost.

How it works Locks, protected lines, and a filtered model reply
lock items covering a hard requirement  (+ newest education)
recommendSelection()   // greedy weighted set cover, soft reqs
mark lines naming a hard requirement as protected
  → AUTO_TAILOR_SYSTEM_PROMPT   (high effort, 180 s)
  → mergeAiReview()   // DROPS anything touching a locked
                      // include, a protected line, or an
                      // id/index that does not exist
  → fitToOnePage()
  → diffSuggestions(plan, target, base)

The plan is a pure function; the model only ever adjusts it, and its reply is filtered rather than trusted. A total model failure degrades to the tag-based plan plus a warning — never a broken selection.

The trim order is fixed and predictable: bullets of weak unlocked items → those items → unprotected bullets of locked items. The first bullet of a kept item is never removed, so no entry is reduced to a bare job title.

Overrides always win. Switch off a locked item and overrideWarnings explains the consequence in yellow instead of blocking you.

The tailor window after Fit to one page: 11 of 22 items, one page, score 79 versus 84

Step 2 of 2, after Fit to one page

Tailor step 1 asking about uncovered requirements and previously declined skills

Step 1 of 2 · a "no" is remembered, and warned about later

Line-by-line feedback, not a grade. Every item gets a score out of ten, named problems, and up to three rewrites where your original is quoted verbatim and the replacement sits right below it.

This is the coffee-shop bullet: 4/10, flagged Off target · Passive voice · No numbers, with a fix that leads with the verb — and the honest advice to leave the item switched off for backend applications.

How it works The rule that stops invented metrics

Reviews run fire-and-forget and are never awaited by a save. The route takes a per-user lock (meta/review.runningUntil), then reviews ≤ 24 items per request — selected first — in chunks of 6, two in parallel, at high effort inside a 260 s budget.

The parser (src/lib/review/parse.ts) is adversarial on purpose. A suggestion is discarded if it targets a field that does not exist for that section, if its current text is not found verbatim in the item, if the rewrite is empty, unchanged or bracketed — and, decisively:

// a rewrite may not introduce digits the original lacked
if ([...digits(proposed)].some(d => !known.has(d))) return null;

That one rule is what stops a language model from inventing "improved performance by 40%" on your behalf.

Staleness uses the same content hash as the tagger, so a review greys out and says so the moment you edit the text — rather than showing an old number as if it were current.

A coach review scoring an item 4 out of 10 with a rewrite offering Accept or Dismiss

The rainbow hairline is the app's own marker for an AI-produced surface

The Action items card listing the low-scoring item with Open in editor

Low scores surface where you cannot miss them

Start from what you already have. Drop in your current PDF, a Word file, or a photo of a printout. It is read, split into sections, and matched against what is already in your library — merging detail instead of creating near-duplicates.

Nothing is saved until you have reviewed every row; each one is labelled New, Adds detail or Already in library.

How it works Rasterise locally, match deterministically

PDFs are rendered to JPEG in your browser by pdf.js before upload, because the vision model accepts text and image_url parts but rejects file parts outright. It is a Route Handler rather than a server action for two concrete reasons: server actions cap bodies at 1 MB, and the route needs maxDuration = 120.

Duplicate detection is not AI (src/lib/import/match.ts): canon lowercases, strips accents and punctuation, expands abbreviations (Sr., B.S., Ph.D.) and drops company suffixes; titles match on token Dice ≥ 0.8, and work experience additionally requires the same start year. Matches then merge — bullets and skill lists unioned, empty scalar fields filled.

Parsing is lenient by design: the envelope never fails, each item is validated on its own, and rejects become warnings shown in the review step instead of failing the upload.

The import view with a dropzone for PDF, DOCX, TXT and images

?view=import · up to 6 files per session, deduplicated across all of them

The same bullet deserves different advice depending on who wrote it. A two-minute questionnaire is condensed into a short candidate brief that every coaching and tailoring prompt receives — and that is never printed on the résumé.

Look at the hint under Years of professional experience: "Your library dates add up to 1.5 (internships not counted)". The app computed that from stored dates before any model was involved.

How it works Computed facts first, model second

Date-derived facts are local and instant: yearsOfExperience merges overlapping roles and excludes internships; employmentGaps counts from the first non-internship job. Only then does the model suggest the subjective fields — and it is never allowed to guess your location or work authorization. Suggested values stay visibly marked until you touch them.

The brief is regenerated only when the answers hash changes, and a failed generation keeps the old brief rather than leaving you with none.

It is read server-side only — the client never sends it — and every prompt that receives it appends CANDIDATE_CONTEXT_RULE: context for relevance and level, never evidence, never printed. Tagging and import do not receive it at all.

The About you questionnaire with computed hints

/dashboard/about · four steps, all skippable

When one line of overflow is all that stands between you and one page. Margins, font size, leading, space above and below each section, the gap between entries, per-item spacing, keep-together and page breaks.

Each control is Compact · Normal · Relaxed · Custom, where Normal is exactly the template's own values — so an untouched layout renders identically to one that was never opened.

How it works One ordering function, one working layout
users/{uid}/meta/layout
  sectionOrder[]   // header always first
  itemOrder{}      // absent = order by date
  page             // margin mm · font pt · leading em
  sections{}       // above · below · itemGap · indent (pt)
  items{}          // spaceAfter · breakBefore · keepTogether

orderedItems (src/lib/layout/order.ts) is used by the editor, the library, the tailor window and toTypstDoc — so what you drag is what prints. The default is newest-first: current items, then latest end date, then latest start, undated last.

Writes use a plain set rather than a merge, because a merge would resurrect the very keys a reset was supposed to remove.

The Master Editor in advanced layout mode

?view=editor · advanced layout mode

Save a selection, reload it in one click. Alex keeps three: backend/platform, ML/research, and a one-page general version. Each remembers which items were on, which bullets were hidden, and the full layout.

Fixing a typo once fixes it in all three — a variant stores pointers, not copies. The editor knows, and asks before saving an edit that several variants share.

How it works Pointers, and one document read to load
users/{uid}/variants/{id}
{ name, labels[],
  items:  { experience: ["exp-northwind", …], skills: […] },
  hidden: { "exp-northwind": ["…bulletKey…"] },
  layout: ResumeLayout | null,
  templateId: "ledger" }

Loading one is a single document read; applyVariantSelection then reads the current isSelected and hidden values and writes back only the documents that actually change.

Every id inside items must match [A-Za-z0-9_-]{1,128}, so a path can never be smuggled into a doc() call. Variants saved before layouts existed carry layout: null and leave your working layout alone.

The variants page with three saved selections

/dashboard/variants · labels, rename, duplicate, load


Interactive · the real formula

Alex scores 84. Here is the arithmetic — change it yourself.

These are the 14 requirements the app extracted from the Globex posting, with the coverage it actually found in Alex's library. Toggle any requirement's coverage and the score recomputes with src/lib/match/score.ts — the same pure function the app runs in the browser.

Fit score out of 100
84
/ 100 fit
as captured
Must-haves covered
9 / 10
Nice-to-haves covered
3 / 4
Missing must-haves
C++

w = importance × tier. must = 1, nice = 0.4; hard = 1, soft = 0.3. A missing hard must-have costs about eleven points; a missing soft nice-to-have costs less than two. That is the whole point of the tier weight — a posting listing "attention to detail" as a technical requirement cannot sink your score, and ten soft-skill hits cannot hide a missing language.

On Requirement Priority Tier Carriers Strength w w × s Contribution

Credentials and languages are binary — any carrier scores 1.0, because a degree is not "more true" when two items mention it. Everything else is min(1, 0.5 + 0.25 × carriers).


All-in-one insights

80 tags, and the shape they make.

Every item — and the profile headline and summary — is tagged by kind. A tag's weight is simply how many included items carry it, computed on read so it can never go stale. The shape is the story: Alex is heavy on tools and thin on soft skills, exactly as you would expect from a student with two internships, and worth knowing before writing a cover letter.

Alex Morgan's library, by tag kind

Distinct tags per kind · 80 total

01632

Hover a bar for its share.

The Insights view with the profile-shape radar and heaviest-tags bar chart

?view=insights · the same data, in the app

How it works The hash rule that keeps AI cost near zero
contentHash = stableHash(`${key}|${JSON.stringify(contentFields(key, item))}`)
// contentFields excludes dates, ids, isSelected, timestamps and hidden[]
stale = tagsHash !== contentHash        // only stale items are ever sent to a model

Toggling an item, reordering sections, hiding a bullet, loading a variant — none of that changes the content hash, so none of it costs a single token. Editing the text does.

Extraction runs inside the save: stale items go out in chunks of 20, three chunks in parallel, 60-second budget, JSON mode, low reasoning effort (≈ 7 s per chunk). Each chunk carries a small context — headline, degrees, summary — so the model can file items under the candidate's actual field, and the prompt asks for implied concepts as well as named ones: a five-service architecture yields Microservices; a degree yields both the specific degree and the generic Bachelor's Degree that postings ask for. A tagging failure never blocks the save.

Normalisation is a lowercase canonical key plus a built-in alias table (js → javascript, k8s → kubernetes) and model-reported aliases stored per user — accepted only when the canonical form is itself a returned tag name, and never allowed to override a built-in.


One application, end to end

From an old PDF to a tailored one-page résumé.

The order matters: each step's output is the next step's input, and the two expensive AI passes run in the background so no click ever waits on them.

STEP 1

Import

The old résumé is read, deduplicated and dropped into the editor draft. Nothing is saved yet.

→ 22 reviewable items
STEP 2

Save & Exit

Stale items are tagged inline; coach reviews are kicked off fire-and-forget.

→ 80 tags · scores per item
STEP 3

Job Match

The posting becomes 14 structured requirements, scored against the working selection in the browser.

→ 84 / 100 · C++ missing
STEP 4

Reconcile

A background pass at max effort records what a human reader would have inferred.

→ Microservices ← gRPC, FastAPI
STEP 5

Questions

Tailor asks about requirements nothing in the library shows. Answers are kept for next time.

→ "no" to C++, remembered
STEP 6

Plan

A locked, reasoned selection: 20 of 22 items, each line explaining itself.

→ 2 pages, nothing lost
STEP 7

Fit to one page

The trim order runs until the compiled page count is 1, and reports the cost.

→ 11 items · 1 page · 79
STEP 8

Save & download

The PDF is compiled in the browser, then the selection is stored as a reusable variant.

→ a real text PDF

Data protection

Your résumé is not a marketing asset.

No public client access to the database, no telemetry on your content, no résumé text sent to a rendering service, and an explicit rule in every prompt that your words are data — not instructions.

No client touches the database

Every read and write goes through the Admin SDK on the server. There is no client Firebase config in the bundle at all.

firestore.rules → allow read, write: if false — on both databases

Typesetting never leaves your machine

The PDF is produced in your browser from your data. No server receives the document, because there is nothing to send.

typst.ts wasm · only the engine itself is fetched, from your own origin

Two email addresses, on purpose

Your Google sign-in address is never printed. The one on the résumé is a separate field, and the login page says so.

users/{uid}.professionalEmail ≠ .email

Uploads are treated as hostile

Hidden, zero-width and bidi characters are stripped; free text is fenced; structured input is JSON, never concatenated into a prompt.

sanitizeForPrompt · fenceUserText · UNTRUSTED_INPUT_RULE

A hard ceiling on AI spend

Per user, per UTC day, counted from real token usage — and a call made without an attributed user logs a warning in production.

2 M tokens · 400 calls · AsyncLocalStorage context

Link checks that cannot become a scanner

A DNS guard refuses private, loopback, link-local and CGNAT addresses on every hop. LinkedIn is never fetched at all — format only.

ports 80/443 · ≤ 3 redirects · 5 s timeout · 24 h cache

What actually leaves your machine

DataDestinationWhen
Library items, profile, variants, jobsyour own Firestoreon save
Item text — stale items onlyZ.ai text modelduring a save, a backfill, or a review
Uploaded résumé or job postingZ.ai vision or text modelwhen you press Analyse
Candidate briefZ.ai text model, server-side onlywith coaching and tailoring prompts
Résumé text for typesettingnowhere — compiled locally—
Anything at allno analytics, no telemetrynever

Toggling, reordering, hiding a bullet and loading a variant send nothing to any model — the content hash did not change. Tagging and import never receive the candidate brief.


This is a showcase, not software you may use.

quikResume is source-available, not open source. You are welcome to read every line, learn from it and cite it. Copyright © 2026 xuckless. All rights reserved.

Allowed

  • Reading and inspecting the source
  • Personal study, evaluation, security research, reference
  • The temporary local copies that browsing or cloning to read unavoidably creates

Not allowed without written permission

  • Copying, republishing, mirroring or redistributing it
  • Running, hosting or deploying it — publicly or privately, commercially or non-commercially
  • Modifying it or making derivative works
  • Folding any file, function or design asset into another project
  • Using it as training data, fine-tuning data or a retrieval corpus

These restrictions apply equally to every copy, fork, mirror, port and derivative, however obtained. Vendored third-party components keep their own licences. For anything beyond reading, ask first.