Commit graph

825 commits

Author SHA1 Message Date
Adam Brown
b92b0c3eb5
Migrate desktop window decoration to Nucleus 2.1.9 TAO backend (#854)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Holy crap this is so much better!
2026-08-05 23:22:38 -07:00
Adam Brown
39e7e913bb
Load scene content as markdown so block structure survives (#856)
Horizontal rules, images and code fences live in the editor's rich span
manager rather than in the AnnotatedString, so seeding an editor from
getInitialEditorContent dropped every block. Import the markdown instead.

Loading this way exposed several things that depended on the old seed:

- The spell checker's one-shot scan is keyed on the checker, not the
  content, and importMarkdown emits no edit operations, so a scene opened
  once the dictionary was cached was never checked. Run a full check after
  loading.
- updateMarkdownStyles remapped heading styles before the extension's
  config setter rebaked them, leaving the rebake unable to strip the old
  style and appending a duplicate span per heading per config change.
- Block lines render as a lone space, which the prose diff could not tell
  from a blank line, so a scene break added or removed between draft and
  current went unmarked. sceneDiffText swaps in length-preserving
  sentinels to keep the diff in the editor's coordinate space.

Also hoist the Draft Compare editors above the width breakpoint so a
resize no longer resets the merge pane while mergedContent points at the
torn-down editor, give both panes the markdown config effect so they
restyle together, and move the markdown export off the main thread.
2026-08-05 21:54:54 -07:00
Adam Brown
eda6a40a1a Fix tag suggestion click adding the typed prefix
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Suggestion chips were focusable, so pressing one pulled focus off the
tag input and fired commit-on-focus-loss, which added the raw draft and
cleared it. That emptied the strip mid-gesture, so the release never
reached onSelect.
2026-08-05 00:56:49 -07:00
Adam Brown
d1ea053c28
Bake the changelog into the app instead of checking GitHub on load (#850)
The app called the GitHub releases API from ProjectSelectionComponent.init
to decide whether to show an "update available" dialog. That is an
unprompted network request before the user has done anything, which has
been raised as a privacy concern.

The release notes are already in the repo at release time, so bake them
in instead. prepareForRelease writes the new CHANGELOG.md entry to
common/src/commonMain/composeResources/files/changelog.md and commits it
alongside the version bump, so the resource cannot drift from the version
in libs.versions.toml.

On load, ChangelogRepository compares the baked entry's version against
lastSeenChangelogVersion in global settings and shows a "What's New"
dialog when they differ. Dismissing records the version. Fresh installs
are seeded as already-seen, so only upgrading users get the popup;
the old lastDismissedUpdateVersion key is dropped, which is safe because
the TOML serializer ignores unknown keys.

About drops its update check entirely and gains two buttons: Changes,
which reopens the dialog with no network, and GitHub Release, which opens
the browser.

VersionCheckRepository stays for the protocol mismatch dialog, which only
appears after the user has connected to a sync server and exists to tell
them which version to get. It keeps its automatic check and is now that
repository's only consumer.

Links in the notes are clickable via linkifyChangelog, which handles
[label](url) and bare urls, including urls containing parentheses. It is
deliberately not a markdown renderer: entries lead with [New] / [Fix]
tags that a real parser mangles.
2026-08-04 19:04:59 -07:00
Adam Brown
71a13d9e7b
Normalize tag needles for search and suggestions (#844)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Normalize tag needles for search and suggestions

Tags are stored NFC-composed, but the read paths compared raw input against
them, so a needle typed decomposed found nothing. Search queries and both
suggestion services now normalize the needle first.

Suggestion prefixes were also split on the ASCII space alone, so a
part-typed tag after a comma or an ideographic separator offered nothing
even though those separators split tags on save.

Adds normalizeTagNeedle and tagPrefixOf, and folds the two ad-hoc
hash-prefix checks into Char.isTagPrefix so the fullwidth form is
recognized in queries as well.

* Agree tag search with tag storage on where a tag starts and ends

Review of the previous commit found that widening tag recognition on the
needle side alone left three regressions.

parseQuery normalized needles, which let two typed spellings collapse onto
one string; the tag list was still an undeduped List feeding a keyed LazyRow
in Global Search, so `##epic #epic` crashed composition. It also treated a
hash anywhere as a tag opener, swallowing the fullwidth one out of ordinary
free text, and still ended a needle at whitespace while storage ends tags at
commas too. A hash now opens a tag only at a word boundary, needles split on
the storage separators, and the tags come back deduped.

The suggestion strip fired on the last run of the draft but its callers threw
the whole draft away on select, losing tags already typed. replaceTagPrefix
swaps just the run being completed.

Also normalizes tag keys as the index is built, so a tag that reached disk
unnormalized is still reachable, and folds the triplicated suggestion
derivation into rememberTagSuggestions.
2026-08-03 23:33:26 -07:00
Adam Brown
ba727a1a2d
Keep the drop target live while the list autoscrolls (#845) 2026-08-03 23:18:16 -07:00
Adam Brown
3509321b24
Resolve drop targets against drawn row geometry (#842)
* Resolve drop targets against drawn row geometry

* Report row geometry in display order and keep the insert line live
2026-08-03 22:34:19 -07:00
Adam Brown
6d129c1768
Match search on rendered prose, with a scan that does not allocate (#841)
* Match global search against projected Markdown prose

Search compared queries against raw Markdown, so backslash escapes and
emphasis markers sitting between words caused misses, and snippets
rendered storage syntax.

Flatten stored Markdown to the prose a reader sees before matching:
escapes resolve to their literal character, and paired emphasis or code
delimiters are dropped. Pairing follows CommonMark flanking rules, so
literal markers in imported or hand-edited content survive; user_name
and a bare *** divider are left alone.

Timeline dates are a plain-text field and are not projected. Derived
titles go through the same projection as the note list so the two views
agree, and blank projections fall back to the raw source so marker-only
content still gets a title and still appears in tag searches.

Fixes #811

* Scope Markdown pairing to paragraphs and narrow the title change

Delimiter pairing used a single document-wide stack, so an unpaired
asterisk or a backtick used as an apostrophe paired with an unrelated
one paragraphs away. Both characters were deleted, and runs between a
bogus code span were marked inert, which left real emphasis in place and
defeated the cross-markup matching this is for.

Pair within a paragraph only. Emphasis still spans a soft line break.

Strip leading blockquote, heading and bullet markers from every line
rather than only from derived titles, so a title and its snippet agree.
Ordered-list markers are left alone: one line cannot tell "1. Draft" from
"1984. The year everything changed".

Titles now take the first non-blank line of the whole projection instead
of projecting a line in isolation, so emphasis closing on the next line
still pairs.

Revert firstNonBlankLine to its verbatim behavior. Routing it through the
projection reached into Browse Notes, Story Ideas and sync conflict
labels and unescaped backslashes there, corrupting stored paths.

Skip the raw fallback scan when the query holds no character the
projection can remove, which makes it provably redundant.

* Project Markdown in a reusable scan workspace

Global search re-projected every document on every keystroke, allocating
about 36x the source in garbage each pass: one object per delimiter run,
two lists, a StringBuilder and an output String, per scene, per search.

MarkdownProjector holds that work in buffers it keeps. Delimiter runs
move into parallel primitive arrays, the projected prose lands in a char
buffer that is matched in place rather than turned into a String, and
every buffer grows to the widest document seen and is then reused. A full
scan of a 1.25M word project drops from 31.7MB of garbage to none, at the
same wall time.

Two smaller wins came with it: the source is copied into a flat array so
the scan and render index an array instead of paying a CharSequence call
per character on both passes, and the ASCII punctuation test became four
range checks instead of a scan over a 32 character string.

The projector cannot be a field on the use case. Cancelling a search is
cooperative, so the outgoing scan can still be running when the next one
starts, and the two would share a buffer. MarkdownProjectorPool lends one
per scan and keeps it afterwards, so the buffers survive to the next
keystroke without forcing the four scans to run one at a time.

projectMarkdownToPlainText stays as the one-shot convenience over the
same code, so the behaviour is defined in one place. MarkdownProjectionTest
passes unchanged, which is the point: the rewrite is internal.

* Read scenes straight into the scan buffer

Search re-read every scene from disk on each keystroke and took a String
back for each one, about 11.5MB of garbage per pass over a 300k word
project before any matching happened.

SceneDatasource can now decode a scene into buffers the caller owns and
return the char count, so a scan reuses one pair of buffers instead of
taking a string per file. ScanBuffers is the contract; MarkdownProjector
implements it, so the bytes land in the same workspace that projects
them. The same scan now costs 0.42MB at the same wall time.

The bytes are pulled in bulk and decoded from an array. Reading a byte at
a time off a BufferedSource was measurably slower than Okio's own
readUtf8, and the decode was never the expensive part.

Matching follows the text into the buffer: findProjectedMatch works over
whatever the projector holds, and the raw-markup fallback searches the
source buffer, so neither path needs the document as a string. Only the
snippet window is copied out, and only on a hit.

SearchProjectUseCaseTest stubbed loadSceneMarkdownRaw, which the scene
path no longer calls. The stub now fills the buffer it is handed, which
is what the collaborator actually does.

* Carry the projection across every search surface

Rebasing onto develop put #831's unification and #821's projection in the
same tree, and they disagreed.

#831 made four surfaces share one rule, markdownContains, which resolves
escapes only. Global search now projects, so leaving markdownContains
alone would have re-split the surfaces it had just joined: Notes, Timeline
and Story Ideas would still miss a phrase spanning "**emphasis**". It
projects now too, and mirrors the same fallback.

The two PRs also pulled opposite ways on the raw-source fallback. #831
pinned that searching the storage form of prose must not work, and in the
same breath that literal "**Chapter**" must still be found. Under the
projection those need different answers, and #821's gate, "the query holds
any character the projection could remove", cannot give them: a backslash
and an emphasis marker are both removable, so honouring one broke the
other.

containsInlineMarkup replaces it. Only emphasis and code markers open the
fallback, because spelling those out is someone hunting for markup.
Escapes and block markers do not, because "well\-known" is the storage
form of prose and nobody types it. Both of #831's assertions hold.

matchOrPreview keeps the fallback argument and the empty-snippet chain
from #833, which the projection commits had dropped, so a tag-only search
still returns bodiless items.

* Match the query as literal text and nothing else

Global search resolved the query against the prose on screen, then, for a
query containing an emphasis or code marker, searched the raw storage form
as well. That second pass was the query being read as markup: typing
"**Chapter**" found a document whose prose reads "Chapter", because the
asterisks were matched against the source rather than against what the
document displays.

The rule is now one line. A query is literal text, matched against the
prose the document renders as, and there is no second interpretation. The
asterisks are not on screen, so typing them finds nothing.

Nothing about escapes changes, and this is easiest to see in the case that
motivated it. Text stored as "well\-known" renders as "well-known" and is
found by typing that; the storage form is not. Text stored as "well\-known"
renders as "well\-known", backslash and all, and typing that backslash
finds it. Markers the projection leaves alone, "5*4" and "user_name", are
matched where they sit, because there they are prose.

Three assertions pinned the behaviour that has gone, one per surface, and
each now states the opposite. containsInlineMarkup and the projector's
source-side accessors existed only to serve the fallback and go with it.

* Drop a comma from a test name so iOS can compile it

Kotlin/Native rejects a comma in a backticked identifier, so the whole
iosTest compilation failed on one test name. Desktop accepts it, and
compileIosMainKotlinMetadata only covers main, so nothing local caught it.

:common:compileTestKotlinIosSimulatorArm64 does, and runs on a non-Mac
host.
2026-08-03 22:26:31 -07:00
Adam Brown
1b312bc3b2
Allow a project to set the language it's written in (#838)
* Add per-project language setting (#754)

An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.

Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.

The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.

* Fix review findings in the project-language feature

createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.

The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.

* Enforce single-owner persisted formats

The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.

The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.

* Burn down the last raw TOML I/O outside datasources

ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.

With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.

* Pass seedDefaultLanguage in the Android instrumented-test harness

* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
2026-08-03 22:22:00 -07:00
Adam Brown
83c8d4feab
Fix scene drag grabbing the wrong row (#839)
* Fix scene drag grabbing the wrong row

The drag gesture resolves the pressed row against
LazyListLayoutInfo.visibleItemsInfo, which reports each item's target
offset. With animateItem() sliding rows into place, a row is drawn
somewhere other than where layoutInfo says it is, so a press right after
a move grabs whichever row now owns that target slot: the one below.

Drop the placement animation so the layout geometry the whole drag stack
reads is the geometry on screen. Fades on add/remove are kept.

* Resolve the grabbed row by hit test so placement can animate

* Scope the drag candidate to the pointer that claimed it
2026-08-03 21:20:12 -07:00
Adam Brown
a523cd521b
Float the scroll-away footers over their lists (#834)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
The Outline Overview strip was a sibling in the scene list's Column, so
showing and hiding it resized the LazyColumn. Combined with the rule that
pins it open once the list bottoms out, that formed a feedback loop: the
strip appears, the list shrinks, the list can scroll forward again, the
strip hides, the list grows, and it repeats every frame. Fixes #827.

Extract HdScrollAwayFooter, which hosts the strip as an overlay in the
same Box as the list and reports its measured height for the list's bottom
content padding, so the last row still clears it. Hiding now waits until
the list has consumed more downward scroll than the strip's own height,
which keeps a list whose only scroll room is that padding from hiding and
immediately re-pinning.

Apply it to the project list's create bar too, which had the same
resize-on-hide shape.

* Hide the scroll-away footer on direction, not distance

The distance threshold reset on any upward pixel, so the jitter in a real
drag kept it from ever accumulating and the strip hid only sometimes.
Responsiveness matters more here than the narrow case it was guarding:
a list whose only scroll room is the strip's own bottom padding can still
hide the strip and re-pin it at the bottom.
2026-08-02 23:19:38 -07:00
Adam Brown
22eef19f4e
Unify search matching on resolved Markdown (#831)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Match every search surface through one rule

Global search resolved escapes and then retried the raw source for a
query containing a backslash, while the Notes and Timeline filters only
did the first half and Story Ideas matched raw storage. The same query
gave different answers depending on which screen it was typed into.

Drop the retry rather than spread it. Searching the storage form of your
own text is not a thing readers do, and supporting it cost real
precision: a query of \* fell back to * and matched every emphasis
marker in the project, and a query ending in a backslash matched
nothing at all. It also forced the snippet and the title to be built
from different strings, so one result row could spell the same sentence
two ways.

What is left is one rule: resolve the stored escapes, match the query
literally. markdownContains carries it, next to matchesAllTags, and all
four surfaces call it. Names, tags, dates and idea titles are not
Markdown, so matching them as stored is now consistent rather than a
bypass.

Each screen's own composition of the rule is extracted so it can be
tested; composeUi/src/desktopTest covers all three.

* Say what the search rule actually does

The KDoc claimed markdownContains compares the prose a reader sees. It
only resolves backslash escapes, so a note stored as "the **big** dog"
is still unfindable by "big dog" on every surface, which is the #811
emphasis gap. State the limit instead of denying it.

SearchFilterTest claimed to pin each screen's composition of the rule
while composing no screen, so reverting a filter to an inline raw
contains would have kept it green. TimeLineOverviewUiTest now drives a
screen's search field end to end, and the predicate tests say that is
what they are.

Global search resolves and matches separately because it needs offsets
rather than a boolean, so add a test asserting it answers the same as
markdownContains across a table of content and query shapes.

* Drop the comma from a test name Kotlin/Native rejects

Native forbids commas in backtick-quoted names, so common/commonTest
failed to compile for iOS while desktop was fine.
2026-08-02 10:23:17 -07:00
Adam Brown
e7cdad3cdc
Allow accented and non-latin characters in tags (#829)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Allow accented and non-latin characters in tags

The tag pattern used \w, which is ASCII-only on both JVM and Native, so
cleanTags() silently dropped anything like "thème" on the way to disk.
Validate with Unicode-aware character predicates instead, including
combining marks so decomposed input from iOS/macOS survives too.

Fixes #778

* Address review of the Unicode tag fix

Splitting tag input, stripping the `#` and validating the result were all
still assuming ASCII in one way or another:

- Split on any Unicode whitespace and on the fullwidth/ideographic commas,
  so an IME's fullwidth space separates tags instead of fusing them into one
  token that then fails validation.
- Normalize to NFC, so the precomposed and decomposed spellings of a tag are
  one index key rather than two identical-looking ones.
- Validate by code point, so an astral letter (rare kanji, Adlam) is not read
  as two non-letter surrogates and rejected.
- Accept ZWJ/ZWNJ, which Persian and Indic scripts need word-internally, and
  require at least one letter or digit so a mark-only tag has a base glyph.
- Strip a fullwidth `#` prefix as well as the ASCII one.

The tag field now commits chips on the same separators and parses them with
parseTagInput, so what it shows is what survives the save.
2026-08-01 22:40:47 -07:00
Adam Brown
c51fc1ff8b
Resolve Markdown escapes when searching project text (#824)
* Resolve Markdown escapes when searching project text

Global search compared queries against raw stored Markdown, so a phrase
that crosses a backslash escape never matched: "well-known" missed a
document holding "well\-known", and the snippet showed the backslash.

Resolve escapes before matching, and build the snippet from the resolved
text. This inverts exactly what the editor does on save, so it is safe
for imported and hand-edited content too, where a backslash escape means
the same thing.

Nothing else is rewritten. Emphasis, code, link and block markers are
left as stored, because telling syntax apart from a literal character
needs a parser, and guessing wrong silently alters the author's words.

Timeline dates are a plain-text field, so only the event body is
resolved. A query holding a backslash also tries the raw source, so
searching for a literal escape keeps working.

* Resolve escapes in search titles and in-screen search

The title above a result still came from raw storage while its snippet
was unescaped, so one card rendered the same sentence two ways. Resolve
escapes for the title as well.

The Notes and Timeline screens filter the same text with a raw substring
test, so a query that found a note in global search returned the empty
state on the screen that owns it. Resolve escapes there too.

Move unescapeMarkdown out of StoryExportCommon into data/search so the
exporters and search share one implementation rather than two copies,
and take its tests with it. Guard the timeline raw fallback the way
findMarkdownMatch already is, so a query without a backslash stops
rescanning an identical string.
2026-08-01 21:16:35 -07:00
Adam Brown
4804534151
Add visible scrollbars to scrolling screens (#820)
Fixes #787. Settings and other long screens had no scrollbar, so on
Linux there was nothing to indicate the page scrolled at all.

Adds ScrollState, LazyGridState and LazyStaggeredGridState scrollbars
alongside the existing LazyListState one, and wires them into the
screens that scroll: account and project settings, About, project
stats, the encyclopedia/notes/timeline/story-idea detail views and
browse grids, the outline reading column and chapter rail, the scene
metadata panel, and the shared reorderable list.

Compose ships no staggered-grid scrollbar adapter, so that one is
implemented over the state's ScrollIndicatorState.

Desktop draws the bar as a hatched rail: an outlineVariant hairline on
the scrollable edge with 45 degree hatching behind an opaque thumb, so
the hatch that remains marks the part of the document still to come.
Both are drawn only while there is somewhere to scroll. Android keeps
its transient fading thumb, now themed rather than black on white, and
iOS keeps its native indicators.

Screens overlay the bar with scrollBarOverlay(); full-bleed content
reserves MpScrollBarGutter so the rail does not sit on top of it.
2026-08-01 15:45:52 -07:00
Adam Brown
707fa8dece
Warn in the import preview when a scene is too large (#810)
Markdown chapter detection that finds nothing collapses a whole manuscript into
one scene. The first sign of that today is the editor locking up, after the
import has already been committed.

The preview now carries a word count per scene and flags any at or over 10,000
words: an amber notice above the list, and the offending rows tinted and stamped
with their real count. The import stays enabled; one huge scene is a legitimate
thing to want.

10,000 clears almost every real chapter (3,000 to 5,000 typical, ~20,000 for the
longest) and sits far below any whole book, so it fires on the collapse case
without nagging.

Also cuts three main-thread wastes profiling turned up on very large scenes.
SceneEditorUi and FocusModeUi passed getInitialEditorContent(...) to
rememberSpellCheckState unremembered; the value is only read on first
composition, but the argument was still evaluated every recomposition and
rebuilt the whole document's AnnotatedString on the UI thread, while the scene
buffer republishes every 500ms during typing. MarkdownEditField built a fresh
spell checker each recomposition, re-keying the library's full-rescan effect.
countWords no longer materialises a list of every word to count them.
2026-08-01 12:17:55 -07:00
Adam Brown
cace720c9a
Detect Setext and bold chapter titles on Markdown import (#807)
Some checks are pending
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
* Fix download link

* Detect Setext and bold chapter titles on Markdown import

Markdown import only recognized ATX headings, so a manuscript whose
chapter titles were Setext-underlined or merely bolded collapsed into a
single scene.

- Setext headings (`===` level 1, `---` level 2). A `---` only underlines
  a line that opens a paragraph, so scene-break rules and front-matter
  fences stay body text.
- In Auto, bold-only lines become chapters when heading markup produced
  at most a story title and several bold lines agree.
- A Pattern strategy for Markdown mirroring the RTF one, with the chapter
  regex field wired through ImportOptions and the import dialog.

ChapterHeadingLevel becomes MarkdownSplitStrategy now that it carries a
non-level member.

* Keep the word-count guard local to the Markdown importer

Sharing it via ImportStructure meant touching RtfStoryImporter, which #806 is already rewriting.
2026-07-31 21:15:47 -07:00
Adam Brown
3a85c461dd
Stop large story imports from freezing the app
Some checks are pending
Build CI / android-instrumented-tests (push) Waiting to run
Build CI / iOS compile & test (push) Waiting to run
Build CI / iOS UI tests (push) Waiting to run
Build CI / build (push) Waiting to run
Build CI / static-analysis (push) Waiting to run
PublishInternal / publish-google-play (push) Waiting to run
Importing a 100k+ word Markdown story froze the editor and consumed all its
RAM. Parsing was never slow (~60ms for 120k words); creating the scenes was,
and quadratically in their number. Resolving a path by id regex-parsed every
filename in the project, sibling counts re-listed the parent directory several
times per scene, and the order re-padding loop re-scanned once per renamed
sibling.

The cached directory scan now carries a scene-id index and a child-count
index, in ScenePathIndex. A created file is absorbed in place; anything else
drops the cache. The re-padding loop reads what it needs before it rather than
inside it, so a run of renames costs one re-scan instead of one each.

Measured on an in-memory filesystem, importing 400 scenes drops from 2.1s to
~0.3s and 1600 from 37s to ~3s. A real disk gains more, since the discarded
scans are syscalls.

Also builds the import preview off the UI thread, debounced and single
flighted, shows the dialog while it reads instead of leaving the projects list
looking idle, makes the preview pane lazy, and ignores the local scratch
directory.
2026-07-30 21:10:36 -07:00
Adam Brown
6314756c62 Clean up unused and mislabeled string resources
Remove 98 string resources that have no reference anywhere in the source.

Fix five strings that were wrong or misleading:
- notes_delete_toast_success printed the note's numeric id ("Note 4 Deleted").
  Notes have no title, so the toast no longer names the note at all.
- encyclopedia_create_entry_toast_tag_too_short is shown for an empty entry
  name, not a tag. There is no TAG_TOO_SHORT error. Renamed and reworded.
- backup_manager_delete_content_description is a visible button label, not a
  content description. Renamed.
- splash_subtitle duplicated about_description. All six locales already
  translated them identically, so the splash screen now uses about_description.
- "Time Line" is now "Timeline", matching the glossary and every other screen.
2026-07-26 20:54:14 -07:00
Adam Brown
5b17fbf429
Make clients HTTPS-only; add dev self-signed cert support (#743)
Remove the HTTP/HTTPS protocol picker and all ssl plumbing from the
client. Persisted server settings now always resolve to HTTPS, so a
legacy ssl=false server.json is upgraded on load. ServerSettings.ssl is
kept only as an internal seam for the plain-HTTP integration-test server.

Android: drop the permissive network_security_config so cleartext
traffic uses the secure platform default (blocked).

Server keeps its plain HTTP connector for reverse-proxy deployments. In
--dev with no sslCert configured, it now generates and persists a
self-signed keystore (hammer_data/dev-selfsigned.jks) and serves TLS on
a non-privileged port (8443 by default). The desktop --dev client trusts
that cert for loopback hosts only; remote hosts still get full cert and
hostname validation.
2026-07-17 00:52:41 -07:00
Adam Brown
70d902894c
Add optional Terms of Service gate for account creation (#742)
* Add optional Terms of Service gate for account creation

Self-hosters can set an undocumented `termsOfService` path in ServerConfig
pointing at a plaintext file. When set, account creation is gated: the server
answers POST /api/account/create with 451 + the TOS text and a content-hash
version. The client shows a scrollable dialog; accepting resubmits with the
accepted version, declining discards the provisional server settings.

Disabled by default (null path); existing servers are unaffected.

* Cover TOS repository and 451 client handling; harden error-body parsing

Adds direct tests for TermsOfServiceRepository (FakeFileSystem: absent/missing/
blank/populated file, stable and content-derived version) and ServerAccountApi
(201 success, 451 -> TermsOfServiceRequiredException, malformed 451 -> default
failure).

Also broadens error-body parse handling: ktor raises ContentConvertException
(not kotlinx SerializationException) on malformed JSON, so both the create-account
451 path and the shared defaultFailureHandler now catch it and fall back to a
graceful failure instead of letting it escape as an unhandled coroutine exception.

* Fail fast when termsOfService points at a missing or blank file

A configured but unreadable/empty TOS path previously made challenge() return
null, silently disabling the terms gate and letting accounts be created with no
terms at all. resolveServerConfig now validates the path at startup and aborts
(as it already does for unparseable config), so a misconfiguration can't quietly
drop the legal gate.

* Resolve a relative termsOfService path against the config file's directory

A bare `termsOfService = "tos.txt"` previously resolved against the server's
working directory, so a terms file sitting next to config.toml wasn't found.
Relative paths now resolve against the config file's own directory; absolute
paths are unchanged.
2026-07-16 21:02:48 -07:00
Adam Brown
0b6fff1a71 Enable #tag search in Encyclopedia via shared parseQuery pattern
The Encyclopedia search box previously stripped `#` tokens on every
keystroke, making a literal `#` impossible to type and tag search
unusable. Standardize on the same combined-query idiom the project
list and Global Search use: the field holds raw text, and `parseQuery`
pulls `#tag` needles out at filter time.

- Search field binds to the raw query; `#tag` stays as literal text and
filters by tag (substring, case-insensitive, AND-combined) with the
parsed tags shown as reflected chips.
- Tag membership resolves through the shared TagIndexService; the UI
observes the TagIndex so results recompute when the index rebuilds.
- Tapping a tag on an entry card appends `#tag` to the query.
- Name matching stays whitespace-insensitive ("darkforest" finds
"Dark Forest").
2026-07-16 00:49:10 -07:00
Wavesonics
cfd3fefb5c Promote iOS scene editor UI test to full edit+save flow
The iOS SceneEditorWorkflowUITests was scoped to "scene opens" rather than
the Android-parity edit->save flow because the scene-editor-save affordance
never surfaced to XCUITest. Root cause: the iOS EditorTopBar save IconButton
was missing the SCENE_EDITOR_SAVE_TAG testTag that the Android/desktop
variants carry, so even once the edit dirtied the buffer and the button
rendered, XCUITest had no accessibility identifier to find.

- Tag the iOS EditorTopBar save button with SCENE_EDITOR_SAVE_TAG.
- Add a typeIntoEditor(_:into:until:) helper to HammerUITest that re-focuses
  and re-injects until the edit propagates, mirroring the Android
  typeIntoEditor retry loop (guards the enabled=hasReceivedInitialBuffer
  gate on the initial buffer load).
- Promote testCreateSceneOpensEditor to testEditSceneTextThenSave: type into
  the editor, assert save appears, tap it, assert it disappears — matching
  SceneEditorWorkflowTest.editSceneTextThenSave.

Verified on iPhone 16 (iOS 18.6) simulator: TEST SUCCEEDED.
2026-07-14 23:01:08 -07:00
Adam Brown
94dae03333 Update repo references to Darkrock-Studios org
The repository moved from github.com/Wavesonics/hammer-editor to
github.com/Darkrock-Studios/hammer-editor. Update all URLs across
source, build scripts, web templates, docs, store metadata, and
test fixtures.
2026-07-13 16:31:11 -07:00
renovate[bot]
dedca686d8
Update plugin ee.schimke.composeai.preview to v0.16.44 2026-07-12 20:04:06 +00:00
Adam Brown
31d44d4e01 Tag dialog masthead strings and extract hardcoded dialog labels
Extractor: strip leading decorative glyphs ("§ MOVE", "↓ Import") before
matching, resolve same-text keys positionally so a title and button that share
text both tag, and ignore format templates with no literal anchor ("%1$s · %2$s")
that were mis-tagging breadcrumb data.

Extract the hardcoded dialog marker ("§ MOVE"), scene-type meta (SCENE/GROUP/
ROOT), and FormDialog "ESC cancel" hint to string resources. Point the dialog
previews at the resources so the recorder captures exact keys.

StringKeyRecorder dropped JVM-only synchronized so commonMain compiles for iOS.
2026-07-11 22:54:07 -07:00
Adam Brown
2956db5c3f Drop unrenderable/empty screens from screenshot tag pipeline
EncyclopediaUi roots a SharedTransitionLayout that invalidates draw every
frame, hanging the headless render harness; its strings are already covered
by the BrowseEntries and ViewEntry twins. FocusMode has no translatable
strings (icon-only toolbar + empty editor). Leaves 19 screens.
2026-07-11 22:25:31 -07:00
Adam Brown
632807513b Add 8 more tablet preview twins to the tag pipeline
Tablet twins for Encyclopedia, SceneList, SceneEditor, FocusMode, DraftsList,
ViewNote, ViewTimeLineEvent, and StoryIdeasView; register all in the extractor
(21 screens total). Disable clock auto-advance during capture so screens with
infinite animations (editor cursor blink) settle instead of hanging waitForIdle.

Compiles clean. Render verification pending — the desktopTest worker environment
is currently wedged locally (unrelated to this code; BaseTest fails the same way).
2026-07-11 13:42:33 -07:00
Adam Brown
031a4953cc Extract hardcoded ProjectList strings to resources
Move the masthead title, column headers, empty-state, footer labels, and entry
count from string literals to projects_list_* resources read via .get() so they
translate. Surfaced by the screenshot pipeline's untranslated-text report.
2026-07-11 11:14:29 -07:00
Adam Brown
d91c94a1eb Emit untranslated-text lint and document the pipeline
The extractor now records on-screen text that maps to no resource and writes
build/crowdin/_untranslated-candidates.md — a hardcoded-string finder as a
by-product of tagging. Document the whole screenshot pipeline in DEVELOPMENT.md.
2026-07-11 11:09:17 -07:00
Adam Brown
501047fe50 Add project-select, dialog, and outline screens to tag pipeline
New tablet previews: ProjectList, AccountSettings, About, OutlineOverview.
Bring the ProjectCreate and MoveScene dialog previews into the Screen* series
(content-only). Register all in the extractor, which now dedupes repeated
strings (list rows) to a single topmost tag and renders each screen at its own
width so the content-only dialogs shoot at their natural size.
2026-07-11 11:04:30 -07:00
Adam Brown
75c25cebc2 Dedupe tags per string and fix screenshot replace
Crowdin keeps one tag per string per screenshot, so keep only the first placed
occurrence of each string instead of posting duplicates. Include name in the
screenshot-replace PUT, which the API requires.
2026-07-11 10:45:29 -07:00
Adam Brown
492dcbb95c Build exact Crowdin screenshot tag pipeline
Generalize the extractor to render all seven tablet screen previews, each to an
aligned PNG plus a tag JSON mapping placed text nodes to resource keys.

Rewrite uploadCrowdinScreenshots to upload via the Crowdin REST API directly (no
CLI): map each tag's key to its string id (identifier == XML name), then upload
each PNG and replace its tags at pixel-accurate positions. Screenshots are matched
by name and replaced in place so re-runs update instead of duplicating. Defaults
to a dry run; pass -Pcrowdin.live=true to upload.
2026-07-11 10:36:21 -07:00
Adam Brown
45086968ce Prefer scoped recorder key in tag extractor
Map each text node to the recorder's this-screen key first, falling back to the
resolved table then format-string templates, instead of unioning all three. With
string reads routed through .get(), this yields one key per node.
2026-07-11 10:00:11 -07:00
Adam Brown
c3b3289229 Route composeUi string reads through StringResource.get()
Replace direct stringResource() calls with the .get()/.get(args) extension across
composeUi screens so string resolution flows through a single instrumentable choke
point (used by the screenshot tag extractor to map rendered text back to its
resource key). Behavior is unchanged.
2026-07-11 09:58:55 -07:00
Adam Brown
808750aeaa Add string-key recorder and screenshot tag extractor prototype
StringKeyRecorder + LocalStringKeyRecorder capture which resource key produced
which resolved text during a composition (null in production, so no behavior
change). StringResource.get() records through it.

ScreenshotTagExtractorTest renders a screen at an explicit tablet surface size,
captures an aligned PNG plus every text node's pixel bounds, and maps each to a
resource key via the recorder, the resolved string table, and format-string
regexes. Writes image + tag JSON to build/crowdin/ for the Crowdin pipeline.
2026-07-11 09:48:29 -07:00
Adam Brown
21d1719bd4 Add Crowdin screenshot upload task and taller screen previews
- uploadCrowdinScreenshots Gradle task (composeUi/crowdin-screenshots.gradle.kts):
  renders Compose previews and uploads the tablet screen previews to Crowdin
  with auto-tagging. Cross-platform via ProcessBuilder; auth from -P props,
  env vars, or interactive prompt. Defaults to Screen*Tablet*.png.
- crowdin.yml: declare env-based project_id/api_token.
- TABLET_TALL_HEIGHT_DP for screens whose content scrolls past a normal tablet
  height; applied to ProjectStats and ViewEntry tablet previews so their full
  content renders instead of clipping.
2026-07-11 02:06:27 -07:00
Adam Brown
44d252d0dc Fix Encyclopedia image selection
Also fix Drag-and-Drop support on Desktop and Android
2026-07-11 00:27:46 -07:00
Adam Brown
7838b3242d Fix Encyclopedia image selection
Also fix Drag-and-Drop support on Desktop and Android
2026-07-10 23:48:54 -07:00
Adam Brown
daaced68bc Fix Focusmode padding on desktop 2026-07-09 22:22:25 -07:00
Adam Brown
30a3825501 Cleanup ProjectList Masthead on mobile 2026-07-08 14:24:04 -07:00
Adam Brown
3a04f3e791
feat(ios): write logs to disk and add Export Logs to About screen (#729)
Previously iOS only logged to the console (getLogDirectory() returned null
and the About section was a no-op), so logs couldn't be retrieved for bug
reports.

- getLogDirectory() now resolves to Application Support/logs (OS-managed,
  not user-visible, not purged like Caches).
- Add an iOS FileLogger (Antilog) mirroring Android: delegates console output
  to DebugAntilog and appends each line to a per-run file, with the same
  single-consumer-coroutine write model and log culling (MAX_LOGS).
- Wire FileLogger into debugBuild() so logs persist on every launch.
- Implement the About-screen Export Logs button: zips the logs directory
  via the shared zipDirectory() (kmp-zip-okio) and shares the archive
  through a UIActivityViewController, matching Android's zip-and-share.
2026-07-08 09:29:15 -07:00
Adam Brown
a68aced919 Drop JetBrains vendor pin from common/composeUi toolchains
Pinning the toolchain vendor to JetBrains broke the F-Droid Android build,
which skips the foojay resolver and has no JBR to satisfy the constraint,
so configuring :common:testAndroidHostTest's javaLauncher failed. The
languageVersion pin alone keeps compile and test JVMs on the same version,
which was the original intent. Desktop keeps its JBR pin (needs JBR, and is
excluded from the F-Droid build).
2026-07-07 23:20:30 -07:00
Adam Brown
49047bb989 Add hover/tap tooltips to the writing-activity heatmap 2026-07-07 21:08:29 -07:00
renovate[bot]
304c0bac28 Update plugin ee.schimke.composeai.preview to v0.16.24 (#724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-07 21:08:29 -07:00
Adam Brown
ae9b1f9f3b
Show an "update your client" dialog on sync protocol mismatch (#721)
The server rejects a mismatched protocol version, but API error
responses carry no body, so the client could only ever see a bare
status code. Return 426 Upgrade Required (was 400) and echo the
server's protocol version in a response header, so the mismatch is
detectable and the client can tell which side is behind.

The Api choke point emits to a new app-scoped ProtocolMismatchRepository
on any 426, and both modal routers surface a dialog that pulls the
latest release from the GitHub version check to point the user at the
update.
2026-07-06 00:29:49 -07:00
Adam Brown
b4e4a08702
Story Ideas: account-level idea capture with sync (#720)
Quick-capture story ideas as tagged markdown blobs in a new Project
Selection tab, stored one file per idea in .ideas/ and promotable into
a project. Offline-first; syncs as a phase inside the account sync
session (shape-agnostic server storage, hash-baseline conflicts,
tombstone/outbox deletion, ideasStateHash skip for unchanged sets).
Unifies idea + project tag suggestions behind AccountTagService.
2026-07-05 01:49:04 -07:00
renovate[bot]
77730c1f21
Update plugin ee.schimke.composeai.preview to v0.16.15 (#719)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-05 01:44:05 -07:00
Adam Brown
54f04c99ef Add Project Tags with tag search on the project list
Projects can now be tagged in Project Settings, with suggestions drawn
from the user's other projects. Tags show as chips on project rows, and
a new search bar on the project list filters by name and #tag using the
same query syntax as Global Search (parser extracted to a shared
data/search module). Conflict resolution gets a Tags row, picked as a
unit like the other project-data fields.

Sync safety:
- Tags hash with zero bytes when empty, so all existing hashes (synced
baselines and server rows) stay byte-identical; golden-pin tests
enforce this.
- The server now stores project data as an opaque blob with a
client-supplied hash (like entities), validating only that the
payload decodes; undecodable rows heal via re-upload. Adding fields
to ProjectData no longer requires server changes.
- Fast-forward records the hash of what was actually stored, so an
out-of-date client can no longer strip and delete fields a newer
build added. Documented in SYNCING-PROTOCOL.md.
- HAMMER_PROTOCOL_VERSION bumped to 3: older servers decode project
data destructively and would silently drop tags.

UI: the redundant projects-page heading is removed; search reveals via
a masthead toggle. New design-system pieces: HdClearGlyph and
HdCollapseGlyph (drawn glyphs, replacing misaligned text "×" and the
ambiguous double-X in search strips) and an HdSearchRow molecule now
shared by all four searchable screens.
2026-07-03 23:12:04 -07:00
Adam Brown
edd5bf3a61 Add "Move To" dialog for deterministic scene moves
Dragging a scene across a large story is impractical, so scenes and
groups now have a Move To action (context menu / swipe / long-press)
that opens a dialog with a searchable group picker and a 1-based
position field. Move math lives in SceneMoveUtils and is tested
end-to-end through the real SceneRepository.

Also improves drag & drop: collapsed group rows now accept drops in
thirds (before / inside / after), with the insert line drawn at the
group's edge when the anchor row is hidden, and per-event drag work
now resolves rows against the visible list instead of walking the
whole tree.
2026-07-03 10:11:57 -07:00