Skip to content

Let a CV emphasise a word, and give the editors somewhere to do it - #102

Merged
IanHeinrich merged 12 commits into
mainfrom
claude/markdown-text-fields-irk8ac
Aug 26, 2026
Merged

Let a CV emphasise a word, and give the editors somewhere to do it#102
IanHeinrich merged 12 commits into
mainfrom
claude/markdown-text-fields-irk8ac

Conversation

@IanHeinrich

@IanHeinrich IanHeinrich commented Aug 26, 2026

Copy link
Copy Markdown
Owner

What and why

#98 removed CvBullet.label and said why in the model's own doc comment:

The technique that does work — emphasising the outcome clause — is
emphasis within the sentence, which a separate field cannot express
anyway.

That deleted the structural mechanism for emphasis in favour of this one.
Until now neither existed, so a bullet was a single uniform run with no way
to stress the metric or the product name in it — and anyone who tried the
obvious thing got literal asterisks in their exported PDF.

**bold**, *italic* and ***bold italic*** now work in every editable
field the CV prints: bullets, summary, references, hobbies, skills, language
names, the work-authorisation line, and every entry header. The four Roboto
faces were already loaded and sitting unused in CvFontSet, so nothing new
is embedded.

Two grammar rules earn their keep, and both are CV-specific. Underscores
are never emphasis, because snake_case and __init__ are ordinary content
in a technical CV. And there is no intraword emphasis — a delimiter with a
letter or digit on both sides neither opens nor closes — because A*A*A is a
UK A-level grade and a realistic Education.grade. CommonMark permits
intraword * and would silently italicise it.

On ATS, this is neutral-to-positive rather than a risk. Bold is a font,
not markup: the extracted text is byte-identical either way, and italic
already ships today via classic_centered's company token. It also removes
junk that reaches the text layer right now, since a typed **deployment**
currently puts literal asterisks in front of a keyword match. The one real
hazard is intraword emphasis splitting a word across two text-showing
operators, which some extractors rejoin with a space — and the A-level rule
above already prevents it. Contact addresses and URLs are excluded from
markup entirely for the same family of reasons.

The editors are the larger half, deliberately. Two icon buttons bolted to
a bare TextField would have made the feature reachable without making it
good. A TextEditingController subclass now paints emphasis live — the word
bold, the markers dimmed — while leaving every character and offset in place,
so the caret and selection behave normally and a marker stays deletable.
Formatting is a bit toggle rather than string surgery, so bold-then-italic
composes instead of stripping a marker. Studio shows the Vault wording a
tailored field is diverging from, which previously required reverting to see.
Prose fields warn when they are running out of page and can open in a roomier
dialog, since a nested Studio row leaves roughly 300px to write a paragraph
through. The About-you panel is grouped instead of nine identical boxes.

Four defects in the editor pane are fixed along the way: a doubled field
label, a doubled value while editing, a second editor stealing focus from the
first, and an editing id outliving the row that owned it.

No new dependency. package:markdown can be configured to do the parsing
parseInline plus a hand-built DelimiterSyntax reaches the intraword rule
— but its escaping collapses \\server\share, and a conformance change in a
minor version would silently re-render every CV. That is the same call this
repo already made for package:intl and CLDR month names, for the same
reason.

How it was verified

  • dart format --output=none --set-exit-if-changed .
  • flutter analyze
  • flutter test --exclude-tags=golden
  • Checked in the browser (required for anything under
    lib/features/studio/widgets/, which has no golden coverage)

That last box is not ticked and it matters — please read before merging.
This branch touches 14 files under lib/features/studio/widgets/ and
restructures the Vault's About-you panel, and none of it has been seen
running. It was built in a headless container with no display; flutter build web --release succeeds, but that proves it compiles, not that it looks
right. The live-styled field, the focus-revealed toolbar, the Vault-original
line, the length warning and the expand dialog all need eyes on them.

Also worth knowing: 889 tests pass, up from 688 on the branch point.

Goldens

  • No golden baselines affected
  • Baselines regenerated via update-goldens.yml, pulled, and the image
    diff reviewed

Both fast paths — PDF and Flutter — rebuild the identical widget for text
containing no asterisk, and example_vault.dart contains none, so the trees
are the same rather than merely equivalent. All 8 baselines pass unchanged.

vault_view_cv_defaults_open.png shows as modified in this branch's diff
against its original branch point, but that is #101's baseline arriving
through a merge — verified byte-identical to origin/main's blob, not a
local regeneration.

The gap worth knowing: no golden renders a Vault editor panel with text
fields in it
(vault_view_cv_defaults_open opens the CV-defaults panel,
which is dropdowns), so the About-you regrouping is genuinely uncovered.

The new stress test carries @Tags(['golden'])? No — it is a pure parser
test with no rendering, so it is correctly untagged and runs in the Test
job.

Anything else

Merged main twice while this was open, and the second one needed a fix git
could not flag.
#101 moved workAuthorization out of the header's wrapped
contact run onto its own full-width line — correctly, since a pw.Wrap cell
cannot break and a long one ran off the margin. That structure wins and the
emphasis followed it there. But photo_header builds its own header, so #101
gave it a second work-authorisation site, and that one auto-merged in clean
as a plain pw.Text — silently outside this branch's coverage. Emphasis typed
there would have printed as asterisks in one template out of three with
nothing failing. It now has a per-template test; reverting that site fails it
by name.

The stress test found a bug in itself before it found one in the parser.
cv_markup_stress_test.dart runs CommonMark's own 152 emphasis and
backslash-escape inputs, CV-realistic nasties, and 20,000 seeded random
strings against seven invariants. The first version passed while a
deliberately re-introduced bug was present — stripping asterisks from both
sides before comparing is structurally blind to a dropped marker. The fix is
an accounting invariant: a matched pair takes the same count from each end, so
consumed asterisks can only ever be even. The mutant is then caught by the
fuzzer alone, at fuzz[190]; no hand-written case found it.

The split between the two test files was verified by breaking the parser four
ways rather than assumed. Dropped markers, broken coalescing and unspliced
leftovers fail the stress file; removing the intraword rule leaves every
invariant intact and fails cv_markup_test.dart instead. That is documented
in the file so the apparent gap does not get "fixed" later.

Deliberately left out. CvVaultPruning._isBlank needed no markup
awareness — the plan assumed a field of ** renders as nothing, and building
the parser disproved it: unmatched delimiters are literal, and structurally no
non-empty string can strip to empty. _TextOverride.normalize keeps comparing
raw strings on purpose, since **foo** and foo render differently and are
genuinely different overrides.

Follow-up not done here. CvDraft.workAuthorizationOverride exists and is
written by the translation pass, but has no Studio hand-edit UI — a
pre-existing hole, noted rather than widened. And the reasoning for declining
package:markdown currently lives in this PR body rather than in the code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU

claude added 12 commits August 25, 2026 23:27
Adds the parser for `**bold**`, `*italic*` and `***bold italic***`, and
the plain-text form everything that is not a renderer compares against.
Nothing calls it yet.

This is the successor to the `CvBullet.label` field removed in #98: that
commit deleted the structural bold lead-in on the grounds that the
technique which actually works is emphasis *within* the sentence, which a
separate field cannot express. Until now neither existed.

Two rules are worth the attention they cost:

Asterisks only, never underscores, because `snake_case` and `__init__`
are ordinary content in a technical CV and `_italic_` would mangle them.

No intraword emphasis — a delimiter with a letter or digit on both sides
neither opens nor closes. CommonMark permits this for `*`; permitting it
here would italicise `A*A*A`, which is a UK A-level grade and a realistic
`Education.grade` value. The same rule removes this feature's one genuine
ATS hazard, since emphasis inside a word splits it across two
text-showing operators that some extractors rejoin with a space.

An invariant test pins the property everything downstream leans on:
`stripCvMarkup` is defined as the concatenation of the parsed runs, so
the text the PDF draws and the text search compares can never disagree.
It also pins that no non-empty string strips to empty — unmatched
delimiters are literal, and two delimiter runs can never be adjacent — so
the blank checks in `CvVaultPruning` stay correct on raw strings and need
no markup awareness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
Routes the text a user types through the markup parser on its way to the
page, so `**bold**` reaches the reader as bold rather than as asterisks.
The four Roboto faces were already loaded and cached; nothing new is
embedded that was not already sitting in `CvFontSet` unused.

Covered here: bullets, the summary and references prose, hobbies, skill
categories and labels, language names, the headline, and the
work-authorisation sentence. Emphasis is laid *over* a token rather than
replacing it, by passing null for an absent flag — so markup can add bold
or italic but never take it away, and a template's type scale still wins.

Fields glued together before rendering are parsed one at a time and
joined with unparsed separator spans. Joining first would let an unclosed
marker in one hobby pair with one in the next, which is emphasis leaking
across a boundary the user cannot see.

The work-authorisation line is the one part of the contact block that is
parsed. It is a sentence someone wrote; the rest are addresses an ATS
matches with regexes over the text layer, where a stray asterisk is how a
match stops happening. URLs stay literal for a different reason: a link
is also its own destination, and markup in the printed text but not the
destination is a silent divergence.

`bodyText` becomes concrete and the three templates keep only what they
actually differed in — `classic_centered` overrides `bodyAlign` to
justify, and the other two overrides were identical to the base and are
gone.

Text with no asterisk takes a fast path that rebuilds the exact `pw.Text`
it did before, so a CV carrying no emphasis is byte-identical. That is
what lets all seven golden baselines stay valid, and the suite confirms
it. The new PDF tests assert the observable fact rather than the intent:
the italic face is absent from a plain bullet and present in an
italicised one, and the bold-italic face appears only for `***`.

Also corrects a claim that had gone stale in `cv_design_tokens_pdf.dart`
and CLAUDE.md — it is not the only file there importing `pdf`, and has
not been for some time. The live convention is the `*_pdf.dart` suffix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
A role, a company, an entry location, a project or publication title, a
citation, a qualification, a grade, education details — every one of
these is a field someone edits, several of them newly so. Typing
`**bold**` into a job title and getting asterisks back would read as a
bug rather than as a boundary, so the header methods each template
overrides now go through the parser too.

Two shared row helpers took strings and now take spans, because only the
caller knows which side is user text: `compact`'s `_labelledRow` and
`classic_centered`'s `_headerRow`. The right-hand side of the latter
carries an entry location in some calls and a composer-formatted date
range in others, and those must not be treated alike — `_metaSpans`
marks the ones that are never parsed.

Every header that glues several fields onto one line now parses them
individually and joins with unparsed separator spans. `compact` and
`classic_centered` folded grade and details onto the qualification line
with a comma, and `photo_header` folded institution, location, grade and
details after an " at "; joining first would have let a marker in one
field pair with a marker in the next, which is emphasis crossing a
boundary the user has no way to see.

The new test asserts this per template rather than once, since the
methods are overridden separately in each. Bold-italic is the
discriminator: no template sets both on a token of its own, so the fourth
face can only be embedded because `***` asked for it.

All seven golden baselines still match, and the full suite is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
The PDF renders emphasis; until now the app around it still displayed the
raw asterisks, so a bolded role read as `**Senior** Engineer` on its own
Vault card. This adds the Flutter half of the adapter and points the
display surfaces at it: the Vault summary cards, Studio's collapsed field
rows, and the entry and bullet titles in its selection lists.

Editing surfaces are deliberately left alone. A field being edited shows
the characters someone typed, markers included, because those characters
are what they are editing — and hiding them would leave no way to remove
one.

The Flutter spans carry only `fontWeight`/`fontStyle`, never a whole
`TextStyle`. Flutter merges nested span styles down the tree, so the
enclosing `Text.rich` keeps supplying the colour, the italic an empty
field is drawn in, and the strikethrough on an omitted one — all three of
which a fully-styled span would have silently dropped from
`StudioEntryFieldRow`'s omitted rows.

Search now compares what a field prints rather than what it stores.
Without this, bolding a word removes it from search: "deployment" would
no longer find `**deployment**`. That is three call sites rather than
one, since two of the Vault's filters do not go through the shared
helper. The skill link picker's exact-match check needed it too, or
typing "Dart" beside a skill named `**Dart**` offers to create a
duplicate.

The LLM length guard now measures rendered characters. Its own comment
says the bound is sized against the page, and a `**` occupies none of it
— counting raw characters would make the limit tighten as someone added
emphasis. A separate, looser raw bound keeps a field of pure asterisks
away from the parser.

Both prompts were silent on formatting, which meant a tailoring or
translation pass would preserve, strip or invent markers at random. They
now say to carry emphasis across with the phrase it marks, never to add
any, and that a lone `*` is an A-level grade rather than half a pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
A field that stores `**bold**` and draws it as literal asterisks tells
you nothing about what will print. `CvMarkupEditingController` paints the
runs live — the word bold, the markers dimmed — while leaving every
character and every offset exactly where it was, so the caret, selection
and backspace behave as they do in any other field.

Dimmed rather than hidden, deliberately. Markers that vanish are markers
you cannot delete, and a field showing something other than what it
stores is one you cannot reason about while editing it.

Bold and italic buttons appear above a field while it has focus, with
Cmd/Ctrl+B and I bound to the same lever. Formatting is undiscoverable
without a control saying it exists, but a button row over every field in
a dense Vault panel is noise; at most one field is focused, so at most
one row is ever on screen, and the space is reserved at rest so revealing
it cannot shove the field out from under the cursor that just clicked in.

The toggle is modelled as emphasis bits rather than as wrapping and
unwrapping a string, which a test caught: applying italic to an already
bold word was stripping a marker instead of composing. A delimiter run's
length is which emphases are on, so applying one flips its bit. Bold then
italic gives bold-italic, removing bold from a bold-italic word leaves it
italic, and pressing twice returns the original text rather than
accumulating `****`.

Which fields offer this is opt-in and matched exactly to what the
renderer honours, so the fields showing a bold button are the fields that
would draw one. Contact addresses, URLs and — via a flag on Studio's
editor — the AI job-description box get nothing, that last one because a
pasted job ad is never printed and emphasis in it would mean nothing.

Also fixes a real parser bug found while building the controller: a
delimiter stranded inside a pair that closed over it was being dropped
from the text rather than only from the matcher, so `*a*a*` printed "aa"
and silently lost an asterisk someone had typed. CommonMark removes those
from the stack, not from the page. The invariant test that should have
caught it only checked the output had not grown; it now checks that every
character which is not a marker survives verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
Studio's undo button reported that a field had been rewritten for this CV
but never what it had been rewritten *from*, so the only way to read the
Vault's own wording was to revert and lose the rewrite. An open editor now
shows it above the box, dimmed, and only when it is worth showing: not for
an untailored field, not when the Vault is empty, and not when the
override happens to match — printing the same sentence twice is noise
rather than context.

It is drawn through the markup renderer, so the original reads the way the
CV prints it rather than as raw asterisks.

`TailorableField` carries the Vault value to make this possible. It was
already in scope at all fifteen construction sites; none needed new
plumbing to reach it.

Two duplications go with it. The row already names the field directly
above its own editor, so the editor no longer sets a label of its own —
that was rendering "Grade" stacked over "Grade". And the collapsed
preview kept showing the value while the box below held the same text,
which made one field read as two; the value now gives way while editing
and the label stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
The product's premise is a document that fits, and until now the editor
said nothing about length — a 700-word summary was discovered in Studio's
preview, or by an LLM pass silently refusing to rewrite it.

Prose fields now show how much room is left, but only once that is nearly
spent. A permanent counter under every field would be noise for a bound
almost nothing reaches, and the bound exists to catch a field that has
swallowed the document rather than to police how much anyone writes. Past
the limit it states the consequence instead of a number, because the
number is not the actionable part.

It counts printed characters, so adding emphasis never brings the warning
closer — the markers occupy no page. That is the property the test pins:
a passage comfortably inside the limit stays silent even when every word
in it is bolded and the raw string is well over.

Single-line fields get no budget. A role or a skill label could not
plausibly reach a page-sized limit, so a counter there would be chrome
for something that never happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
Two things that were making the editors harder to use than the writing
deserves.

Studio's editor pane is about half a window wide with the preview beside
it, and a nested field row keeps roughly 300px of that once the nav
column, indents and icon cluster are taken out. That is a slot to write a
sentence through, and the summary and the longer bullets are paragraphs.
Prose fields now offer a roomier dialog, built the way every other dialog
here is — through `DialogService`, registered by the CLI — rather than an
in-widget `showDialog` that would have been the first of its kind. It
commits on Save rather than per keystroke, so backing out of the detour
leaves the field as it was.

The About you panel was nine identical outlined boxes in one column: a
name, an email and a four-line summary given the same weight and the same
gap, with nothing saying they are three different kinds of thing. It now
groups under the same headings the Vault's card list uses — who you are,
how to reach you, what you say — so the panel reads with the rhythm of
the list it was opened from rather than as a form to get through.

Education keeps one subject and so gets no headings, which over a single
field would read as a section that had lost its contents. Its prose field
gets the wider gap instead, which is the whole signal needed to say that
everything above it is a fact with one right answer and this is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
Opening a second inline editor stole the caret from the first while both
stayed open and both stayed tinted, with nothing saying which one was
being typed into. Only one row holds an open editor now, which is how
`StudioSkillSelector` already worked — the selector list was the odd one
out for holding a set.

An open editor's id also outlived the row that owned it. Dropping an
entry from the CV hides its rows but left the id behind, so re-including
that entry silently reopened an editor nobody had asked for.

A page-level row — the headline, the summary, the references note — drew
its text with a bare `TextStyle()`, inheriting whatever ambient default
applied. It was the one type treatment in the pane that was not a
deliberate choice; it now takes a token like everything else.

And clearing a tailored field does not blank it: the override is removed,
so the Vault's wording comes back. That is the right behaviour and it
stays, but watching text reappear on its own reads as the edit having
failed, so the box now says so before it happens — and only where the
Vault actually has something to fall back to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
The parser next door is hand-rolled, which means this codebase owns the
delimiter-stack algorithm and the class of bug that comes with it. It has
already shipped one: a delimiter stranded inside a pair that closed over
it was dropped from the text rather than only from the matcher, so
`*a*a*` printed "aa".

`cv_markup_test.dart` pins what specific strings mean and stays the
readable specification. This file asks a different question — whether the
parser can be made to misbehave — and needs a different oracle. On twenty
thousand inputs nobody can say what the right runs are, but everybody can
say the text must survive.

The corpus is CommonMark's own emphasis and backslash-escape examples,
which exist because they broke somebody's parser once. Their expected
HTML is deliberately not asserted: this parser diverges on purpose, so
the spec's answers are the wrong oracle while its inputs are still the
best adversarial corpus going. Alongside them sit the things real CVs
contain — A-level grades, UNC paths, SQL wildcards, shell globs, C
pointers, combining accents, RTL text — and a seeded fuzzer for what
neither list thinks of.

The invariants are chosen to be provable rather than plausible. The
sharpest is marker accounting: a matched pair always takes the same count
from each end, so the number of asterisks consumed can only ever be even,
and an odd count means one went missing. That check exists because the
first draft of this file did not have it and did not catch the original
bug — stripping asterisks from both sides before comparing is blind to a
marker being dropped.

Checked against a deliberately broken parser four ways. Dropped markers,
broken coalescing and unspliced leftovers all fail here. Removing the
intraword rule — which would silently italicise every A-level grade in
the app — leaves every invariant intact and fails `cv_markup_test.dart`
instead, which is the division of labour working rather than a gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
#101 landed on the same field this branch had just taught to carry
emphasis, and moved it. It is no longer one cell of the header's wrapped
contact run — it is a sentence someone wrote, and as a single cell it
could not break, so a long one ran off the right margin. It now prints on
its own full-width line.

That structure wins; the emphasis follows it there rather than staying
where it used to be. Two conflicts resolved that way: the renderer keeps
main's own-line layout with markup applied to its new home, and the Vault
search keeps main's newly searchable field while still comparing what a
field prints rather than the markers around it.

The merge also needed a fix git could not have flagged. `photo_header`
builds its own header, so #101 gave it a second, separate
work-authorisation site — and that one auto-merged in clean as a plain
`pw.Text`, silently outside this branch's coverage. Emphasis typed there
would have printed as asterisks in one template out of three, with
nothing failing.

So the line now has a test, per template, asserting the bold-italic face
is embedded only when the markup asks for it. Reverting the photo_header
site to what auto-merge left behind fails it by name, which is the point:
this field has moved twice in two PRs and should stop being able to move
quietly.

The `vault_view_cv_defaults_open` baseline is #101's, arriving through the
merge byte-identical — not a local regeneration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsmGo6QAsQ7RQpNxKUSWeU
@IanHeinrich
IanHeinrich merged commit 6a8b229 into main Aug 26, 2026
3 checks passed
@IanHeinrich
IanHeinrich deleted the claude/markdown-text-fields-irk8ac branch August 26, 2026 09:16
IanHeinrich pushed a commit that referenced this pull request Aug 26, 2026
Two files were fetched from Google on every page load: CanvasKit from
gstatic.com and Roboto from fonts.gstatic.com. Both are Flutter Web
defaults rather than choices anyone made here, and both sit badly in an
app whose claim is that a CV never leaves the device — index.html already
sets dartPdfJsBaseUrl locally for exactly that reason, and these two were
simply missed.

CanvasKit costs nothing to bring in-house: `flutter build web` already
copies it into build/web, so those bytes were being deployed to Pages and
then ignored in favour of a CDN round trip. The loader now points at them.
Roboto is registered as a `fonts:` family from the four faces already
bundled for the PDF, so CanvasKit finds it in the asset manifest instead
of downloading it. The app now boots and renders with every non-origin
request blocked, which it could not do before.

Not request-free, and the comments say so rather than overclaiming: a
screen showing a RegionPreset flag still pulls Noto Color Emoji, because
the flags are emoji and no embedded font covers them. Closing that means
bundling ~10MB or drawing the flags as assets; neither belongs here.

All eight golden baselines move. The bundled Roboto is a different cut
from the SDK's, which is what golden_toolkit was loading, so every glyph
rasterizes a hair differently — layout, line breaks and positions are
identical, checked against the diff rather than assumed. The new
baselines are the more honest ones: they use the font that now ships.

A release is one edit again. `BackupService._appVersion` was a second
literal of pubspec's `version:`, kept in step by a test — which catches
drift but does nothing about the failure that actually happened twice
this week, a merge with no bump at all and so no deploy. It now reads a
constant `build_version` generates from pubspec. A dev dependency
producing a checked-in constant, not a runtime one: the reason
package_info_plus was declined still holds, since nothing reads this back
and the app should not carry a plugin to learn its own version. The test
survives, re-aimed at a stale generated file, and was confirmed to fail
when handed one.

2.23.0 covers this and the markdown emphasis work in #102, which merged
without a bump and so is on main but was never published.

Also adds tool/serve_web.mjs and a run-app skill. Running this headlessly
needed four workarounds; two are now fixed above, and the other two are
worth writing down rather than rediscovering: Flutter's engine throws
RangeError from the JS Intl.Locale constructor when a headless browser
reports "en-US@posix", and usePathUrlStrategy 404s on refresh under a
plain static server. .claude/ stays ignored for local config, but skills
are the opposite of machine-specific, so they are shared now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kPZNZ5pCETb2mHRaz25Kz
IanHeinrich added a commit that referenced this pull request Aug 26, 2026
…103)

Two files were fetched from Google on every page load: CanvasKit from
gstatic.com and Roboto from fonts.gstatic.com. Both are Flutter Web
defaults rather than choices anyone made here, and both sit badly in an
app whose claim is that a CV never leaves the device — index.html already
sets dartPdfJsBaseUrl locally for exactly that reason, and these two were
simply missed.

CanvasKit costs nothing to bring in-house: `flutter build web` already
copies it into build/web, so those bytes were being deployed to Pages and
then ignored in favour of a CDN round trip. The loader now points at them.
Roboto is registered as a `fonts:` family from the four faces already
bundled for the PDF, so CanvasKit finds it in the asset manifest instead
of downloading it. The app now boots and renders with every non-origin
request blocked, which it could not do before.

Not request-free, and the comments say so rather than overclaiming: a
screen showing a RegionPreset flag still pulls Noto Color Emoji, because
the flags are emoji and no embedded font covers them. Closing that means
bundling ~10MB or drawing the flags as assets; neither belongs here.

All eight golden baselines move. The bundled Roboto is a different cut
from the SDK's, which is what golden_toolkit was loading, so every glyph
rasterizes a hair differently — layout, line breaks and positions are
identical, checked against the diff rather than assumed. The new
baselines are the more honest ones: they use the font that now ships.

A release is one edit again. `BackupService._appVersion` was a second
literal of pubspec's `version:`, kept in step by a test — which catches
drift but does nothing about the failure that actually happened twice
this week, a merge with no bump at all and so no deploy. It now reads a
constant `build_version` generates from pubspec. A dev dependency
producing a checked-in constant, not a runtime one: the reason
package_info_plus was declined still holds, since nothing reads this back
and the app should not carry a plugin to learn its own version. The test
survives, re-aimed at a stale generated file, and was confirmed to fail
when handed one.

2.23.0 covers this and the markdown emphasis work in #102, which merged
without a bump and so is on main but was never published.

Also adds tool/serve_web.mjs and a run-app skill. Running this headlessly
needed four workarounds; two are now fixed above, and the other two are
worth writing down rather than rediscovering: Flutter's engine throws
RangeError from the JS Intl.Locale constructor when a headless browser
reports "en-US@posix", and usePathUrlStrategy 404s on refresh under a
plain static server. .claude/ stays ignored for local config, but skills
are the opposite of machine-specific, so they are shared now.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants