Remove the retired applications, asset-specific libraries and DJ pack tool
from Makepad, together with their workspace and launcher entries. All 981
removed source paths are accounted for in the private Stage repository.
Keep public AI chat generation through the AI Hub's generic job runner.
Extract shared SHA-256 and UDP binding into core_util so the public hub and
model crates no longer depend on the relocated asset libraries. Preserve
the retained public coverage in the split wasm validation script.
Validation on the exact cleanup tree in an isolated checkout:
- Release checks: core_util, model, aichat, WM and Builder.
- Release builds: aichat, WM and Builder.
- Existing tests: core_util 5, model 30, aichat 7, Builder 2 passed.
- Core/model checks: wasm32, Linux and Windows passed.
- No warnings in the successful checks, builds or tests.
Known baseline: WM library tests do not compile because the unchanged
style-transition assertion compares seven expected weights with eight.
The unrelated working-tree correction is intentionally outside this commit.
The SDK came from `env!("CARGO_MANIFEST_DIR")`, so every copy of the binary had
its own NDK at its own path. Switching copies changes `CMAKE_C_COMPILER`, and
cmake then deletes its cache and re-configures *without* the `-D` flags, losing
`CMAKE_SYSTEM_NAME=Android`. Deps like `aws-lc-sys` then build for the host and
Darwin forces `-arch arm64` into the NDK clang.
* default to `~/.makepad/<host-dir>`, independent of which binary runs
* migrate an existing per-checkout SDK with a single `rename`
* ScrollBar: add `show_handle` for a view that scrolls without a grabbable bar
The handle is both the visual and the hit target, and `show_scroll_x`
gates wheel/trackpad input too, so there was no way to keep a view
scrollable while dropping the bar a user can click.
* `show_handle: false` skips drawing the handle and its hit test.
* Wheel, trackpad, finger drag and the scroll API are untouched.
* ScrollBarTabs: stop the invisible handle from eating presses on tabs
The tab-bar handle is transparent until hovered, and it runs along the
bottom of a strip whose tabs are exactly as tall as it, so it sits over
the lower edge of every tab. `TabBar` hands presses to the scroll bars
before the tabs, so while the strip overflows, a press near a tab's
bottom grabs a bar nobody can see instead of selecting the tab.
* Default `show_handle: false`, so `TabBar`/`TabBarFlat` scroll only by
wheel, trackpad and drag.
* macOS: re-arm the display links when a window leaves the Dock
`pause_display_link` only sets `setPaused: YES` and keeps the links, so
`display_link_needs_rearm` stays false and `ensure_timer0_started` never
clears `timer0_armed`. A work beat while minimized arms the NSTimer with
that flag set, so deminiaturize early-outs before anything unpauses the
links and the restored window paces on the timer, not its own panel,
until the next idle downshift.
Clear the flag first, like the pointer-capture release does.
* Vulkan: stop printing the loader's startup narration by default
`vulkan_debug_messenger_create_info` asked for `INFO` severity on the
`GENERAL` message type, which is the channel the Vulkan loader narrates
itself on. Every run dumped around ninety lines naming each directory it
searched for layer and ICD manifests, each manifest it found, and the
layer callstack it assembled, before the app had drawn anything. None of
it comes from the validation layer -- that only loads under
`MAKEPAD_VULKAN_VALIDATION` -- so it was noise on every Linux desktop,
Android and OpenXR run, on every machine.
* Subscribe to `ERROR | WARNING`, adding `INFO | VERBOSE` only when
`MAKEPAD_TRACE=vulkan.debug` is set. The driver skips the callback for a
severity we did not ask for, so the loader no longer formats the lines
either. Validation errors and warnings still print unconditionally.
* Route the informational branch of the callback through `trace!`, so it
carries the topic that enabled it like the `gl.*` and `shader.*` ones.
* `devices` logged a line per software device it stepped over, which fires
on any machine carrying lavapipe -- that is most Mesa systems. Move it to
`MAKEPAD_TRACE=vulkan.device`, and instead say so once when software
rasterizers were the *only* devices found, since every caller then
reports no usable device, which reads as if the machine had no Vulkan at
all rather than no accelerated one. What happens next is left to the
caller that decides it: `linux_wayland` already logs its OpenGL ES
fallback.
* Two Android camera-import sites used `warning!` for a plain dump of
image size and format on the success path; they become
`MAKEPAD_TRACE=vulkan.camera`.
A desktop Linux run now prints the one line that says what it got:
Vulkan: NVIDIA GeForce GTX 1070, graphics/present queue 0
`MAKEPAD_TRACE=vulkan` brings all of it back; the topics are hierarchical,
so `vulkan.debug`, `vulkan.device` and `vulkan.camera` also work on their own.
A test is a ci.splash beside what it tests; the script decides every input and the model only ever judges one picture against one acceptance text. mod.ci: launch (hidden, --remote, user_seq preserved), key, type_text, click, get, snap, wait_log (a * is a gap inside one line), no_errors, grab, quit; step, sleep, check, run; cargo, check_targets (the cargo makepad check matrix, check only for platforms we are not on, a test fails if the two tables drift), test, build, machine (another box over the makepad tunnel), exclusive; judge, accept, ask. The watcher polls git ls-remote once a minute for work and any extra branches, syncs a checkout the CI owns, runs the root script first and alone, then the rest up to a parallel limit behind one shared model judge. The window is a wall of squares, one per script: green passed, orange warnings, red failures, with a detail panel for the selected one.
Scripts: the root ci.splash (workspace check with core warnings denied, the tests), apps/wm (desktop up, switch to macOS by Cmd+Space / type / Return, launch the terminal and the browser, each waited for by the WM's own first-frame line), and one per main app in the default shape. Proven here: apps/wm/ci.splash green in 280 s, fifteen target checks and seven vision verdicts.
Models come from Hugging Face through the hub: registry entries qwen3.5-4b-vision and qwen3.5-9b-vision with exact revisions, sizes and digests, and hub-install, a command line over LocalModels::start_install. vlm-probe reads PNG.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
iOS: VideoFileDecoder::open_audio guarded its Apple path with macOS alone, so iOS fell through to UNSUPPORTED, a constant no Apple target has. It takes the same split as every other entry point in that file.
tvOS: libs/apple_sys opened with a crate guard of macOS or iOS, so on tvOS the crate compiled to nothing and every msg_send! user lost the macro; the crate guard and its 27 inner guards of that shape now name tvOS, and MTLCopyAllDevices is macOS only, which is where it exists. The platform's Apple video playback, player and YUV modules, the 17 guards of the Metal NV12 video path and the texture-pool imports follow, and the tvOS app gains try_metal_device, the lookup the texture adopt path already calls on iOS.
wasm: the window manager's dylib host needs a process, a linker and a loader, so it is native only; the web gets a stand-in with the same surface that refuses every compile through the queue the native host answers on, and libloading is a non-wasm dependency.
Windows: three Unix-only uses in apps/wm/src/clients.rs (Cx, CancellationToken, the grace argument) are guarded to match where they are used, tests included.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`pause_display_link` only sets `setPaused: YES` and keeps the links, so
`display_link_needs_rearm` stays false and `ensure_timer0_started` never
clears `timer0_armed`. A work beat while minimized arms the NSTimer with
that flag set, so deminiaturize early-outs before anything unpauses the
links and the restored window paces on the timer, not its own panel,
until the next idle downshift.
Clear the flag first, like the pointer-capture release does.
The CEF follow-up (libs/cef, widgets/src/browser.rs, apps/browser): BrowserOptions with software frames, evaluate_javascript answered as JSON or the exception text, console messages taken by the embedder, editable_focus, with_cef_browser on the widget, the profile flushed on Event::Shutdown, and a repr(C) mismatch in the FFI fixed.
platform/video: VideoFileEncoder::new_fragmented lays the container down in movie fragments (AVAssetWriter's movieFragmentInterval), proven by tests/fragmented_growing.rs: 90 of 120 frames readable before finish, all 120 after. Windows and Linux write one movie as before. The Apple backend's plain constructor went with it: nothing called it once the fragment-aware one existed.
platform: the audio output fence is seated in the one seam (media_api.rs), so a panic in an app's output closure costs that closure and its buffer, not the device thread, and the taps are fed the silence so a recording keeps its place; its tests adapted to this tree's tap registry. /midi routes inject a message as if a device sent it, declare ports for the app to adopt, read back what the app sent, and reset (platform/src/midi.rs, remote.rs).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The child draws its frame through a texture pass, which on GL renders through an inverted projection, so its glReadPixels rows come in picture order already; the host keeps them as they are, and the two run views sample the texture as stored on every path. The old shader flip on the software path inverted it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A custom-camera pass used to keep GL's bottom-up storage while the 2D passes were inverted, so a 3D scene rendered to a texture came out upside down on Linux and Android GL and nowhere else; it now uploads an inverted copy of its projection as the web backend does. Backface culling follows with a clockwise front face on those passes, and a target allocated taller than its pass keeps the pass at row 0 instead of the far end.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The resolve flipped V and mirrored the whole window on Metal, grab-verified the wrong way round; the mask flipped on Metal and iOS alone. Both textures are ordinary passes drawn with the 2D camera, top-left on every backend.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The 2D camera is GL-style: the top of a pass rect lands at clip y = +1 on every backend. Vulkan's clip space points down, so a pass drawn with that camera has to go through a negative-height viewport, window and capture alike; the window comes out upright and a capture's rows are stored top-left like Metal's. fbfec26ad made both viewports positive to cure an inverted phone desk, and every desktop Vulkan window stood on its head (the Scope report of 2026-09-20). The desk was inverted by its own OS-keyed consumer flips, not by the viewport, so those go: the gauss stack's per-OS flip and its callers, the phone shell's three Android flips and its y_flip shader term, the dock warp's capture_y_flip and its term. The direct display's letterbox blit keeps its positive viewport: its own vertex shader maps uv.y = 0 to clip -1.
Seen right side up by eye on the Arch RTX 5090 box: apps/wm as a Wayland client under sway, the hosted apps inside it, and the linux_direct WM on the panel; and on the Pixel 11 Pro XL the wm-dyn super-app (Vulkan, no GL fallback). Codex review in the session's notes endorses the restores and removals and leaves two flips for a later look: the SSAA resolve's 1.0 and the map shadow mask's Metal flip, both untouched here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Taken from vjroger/work. A full merge of that fork fights this work branch
in widgets; this is the stems crate, vocal mel-band, span-cache lanes,
log_ring, output fence, midi inject, effect_doc, mp3 sniff, mp4 audio
edit, and audio-only file open. Stage on origin/main cargo-checks again.
Every manifest `app_main!` emitted carried three fonts no theme role uses:
`NewCMMath-Regular.otf` for `MathView`, and `Inter.ttf` / `RobotoFlex.ttf` for the opt-in
iOS and Android platform styles. That was 3.6 MB in every package, and it only existed
because a font an app forgot to declare failed silently: `FontFamily::update_font_definitions`
skipped a member whose bytes never arrived, so the text showed as boxes with no log line.
* Drop the forced extras. The manifest is now the font set's fallback chain plus whatever
the app puts in `font_assets`, which is what makepad's own apps already did for `Inter`.
* `font_assets` takes expressions, and `INTER_FONT_ASSET` / `ROBOTO_FLEX_FONT_ASSET` join
`MATH_VIEW_FONT_ASSET`, so an app declares a font by name instead of by path.
* A font that never loads now logs one `error!` naming the path and the fix. A missing
member still degrades gracefully: the family keeps its remaining members.
* The apps that use those fonts declare them: the `wm` family binds both platform faces,
`clock`, `weather` and `director` draw with Inter and can select any style, `terminal`
and the builder use Inter for symbols, `splash` and `aichat` use `MathView`.
`cargo tree` only prints a directory for path dependencies, so for a git
dependency we fell back to the `<crate>.path` file its build script drops
in the target dir. Any tool that prunes the target dir deletes that file,
and a warm cache means the build script never re-runs to recreate it, so
`add_resources` silently found no `resources` dir for `makepad-widgets`
and packaging failed with "font assets declared by makepad.font-assets.v1
are missing on disk". Ask cargo for each package's `manifest_path`
instead, keeping the `.path` file as a last resort.
`drm_sys` carries `#[link(name = "drm")]` but was compiled for every non-Android
Linux target, so an ordinary desktop build fails to link on a machine without
libdrm, even though nothing outside the DRM/KMS backend calls into it.
* Gate the module on `linux_direct`, the same cfg its only callers already
carry: `drm_native_resolution` in `vulkan_linux` and the `direct` module.
`NSView.displayLink` never fires for a window in the Dock, and the 0.2s
NSTimer fallback only starts from inside the timer 0 branch that the link
drives. So minimizing froze the UI thread, and Ctrl+C / SIGTERM / SIGHUP
sat in `REQUESTED` until the window came back.
* swap the paint clock on `windowDidMiniaturize:`/`windowDidDeminiaturize:`
* skip link pacing while every window is miniaturized
* termination worker restores `SIG_DFL` if it gives up, so the process
can't end up unkillable
A record filed as "Café del Mar" could not be found by anybody typing
"cafe". The tokenizer breaks a run at every non-ASCII character, so the
title indexed as `caf` + `e` and the query asked for `cafe` — three
terms that never meet. Which spelling a name happens to carry was
deciding whether it was reachable, and nobody decided that.
The two sides are now deliberately asymmetric, and the law is one line:
index(text) = tokenize(text) ∪ tokenize(fold(text))
query(text) = tokenize(fold(text))
so the index is a superset of what any query can ask, and folding is
idempotent, which means "café" and "cafe" ask exactly ONE question —
they have to, or the cursor fingerprint and the ranking would disagree
between two spellings of one word. The fold happens to the TEXT before
tokenization, because by the time there are terms the accent has
already split the word and there is no per-term place to hang a folded
form.
The fold is this repo's own twenty-arm Latin table, not canonical
decomposition: decomposition cannot tell you that 'ß' is "ss" or 'æ' is
"ae", and this repo already answered both, tested, inside the
importer's alias slugger. That table moves to the data crate so the
alias a record is filed under and the terms it is found by fold by
construction rather than by two tables happening to agree; the
slugger's own locked assertions pass untouched, which is the proof the
move changed nothing.
Two guards for the two ways this goes wrong quietly. An ASCII text
takes a fast path that provably tokenizes identically, so every
existing field indexes bit-for-bit as before. And a term both passes
find is counted at the HIGHER of the two frequencies, never their sum —
doubling a weight fails no assertion about which rows come back, only
about the order they come back in, so it would have shipped silently.
The index holds terms, not the rule that made them, so a catalog
written before this holds the wrong ones: schema 15 rebuilds every
posting row from the annotations that produced it. It is a
recomputation from data the store already has, not a repair, and it
takes the same builder the writer uses so the two cannot drift. It
deliberately does not enforce the index-term budget — these rows were
admitted under it already, and failing a migration on a budget would
leave a catalog nobody can open. Alias postings are untouched: an alias
is `[a-z0-9_-]` by construction, so the fold is the identity on every
one and rebuilding would be a whole-table cost for a guaranteed no-op.
Both openers carry it, the server's migration ladder and the embedded
one, and the embedded gate now admits a v14 root rather than refusing
it.
Verified against a real 3.2 MB catalog: it migrated 13 -> 15 on open,
after which "chloe" finds a track titled "…(Chloé Caillet mix)" and so
does "chloé"; "chlo", the truncation the old index held, still answers,
which is the superset law showing up as behaviour; and the ASCII
searches around it return exactly what they did before. The migration
was not perceptible on that catalog, but I could not isolate its cost
honestly — it commits inside the WAL and the store opens lazily — so no
number is claimed here.
The store's integration tests cannot link in this environment
(sqlite3.lib, pre-existing and unrelated), so the four end-to-end tests
added here are unrun locally; the tokenizer law itself is covered by
unit tests that do run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit d61b9dd4fe3c682c6e597e894f17c4c3a8b4e296)
A path that bends tighter than the stroke's half width has no inner
offset curve: the per-point inner miter points run backwards along the
path and the strip folds over itself there, spikes on the inside of the
bend and doubly blended wedges across it. The true inner edge of such a
bend is the corner where the inner edges of the two segments around it
meet, so every point of the folded run now takes that corner as its
inner vertex and the strip fans around it. Bevel and round joins are
emitted as strip pairs whose inner side collapses to the miter point,
so the two segments' quads no longer overlap; only a segment shorter
than the stroke is wide keeps its two offset points.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
apps/flow-ui is the flow editor again: the graph canvas with its camera,
cards and progress bars, the node faces and inspector, the source and
App views, the running list, the menu bar and the toolbar with the run's
total bar, the template picker behind New, the hub model lists, and the
aichat bridge (`services`) with the port, event and worker wiring.
libs/flowgraph is the reusable graph-canvas widget with a display-only
view model and orthogonal wire routing; the review that removed the
two-fillet restriction from `valid_orthogonal` stays local.
libs/flow reports a turn's progress as stages: a `Stage { stage,
permille }` event carries the named phase (admission, download, load,
prefill, serving) and that phase's own fraction when the producer knows
it, while a node's whole-operation permille is optional and reaches
1000 only with Done; the hub's Loading fraction belongs to its phase
and restarts with each one.
makepad.splash lists the app for the Studio release runner; both crates
are workspace members.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Button: activate from the keyboard when focused
`Button` matched only `KeyFocus` and `KeyFocusLost`, so a button reached with
Tab could take focus and animate, but no key press ever activated it.
* `Space`, `Enter` and numpad enter now emit `Pressed` and then `Clicked`, and
make the same `on_press`/`on_click` script calls a tap does.
* Key repeat is ignored, so holding the key down doesn't re-press.
* Both arms are gated on `enabled`, like the finger paths.
* libs/ai: exclude hub_ui and services from the AI workspace
`cargo install --git ... cargo-makepad` fails with "package
`libs/ai/services/Cargo.toml` is a member of the wrong workspace": the root
workspace lists `libs/ai/hub_ui` and `libs/ai/services` as members, but both
sit under `libs/ai`, which is its own workspace, and cargo gives a package to
the first workspace root above it that doesn't exclude it. `libs/ai/hub` and
`libs/ai/livepipe` were already fine since each declares its own `[workspace]`.
`Button` matched only `KeyFocus` and `KeyFocusLost`, so a button reached with
Tab could take focus and animate, but no key press ever activated it.
* `Space`, `Enter` and numpad enter now emit `Pressed` and then `Clicked`, and
make the same `on_press`/`on_click` script calls a tap does.
* Key repeat is ignored, so holding the key down doesn't re-press.
* Both arms are gated on `enabled`, like the finger paths.
CefAudioHandler next to the paint handlers: Browser::enable_audio_capture
names a rate and a channel count, Chromium mixes the page down to it and
mutes the page's own output while the capture runs, and poll_audio drains
Started / Packet / Stopped / Error on the thread that pumps CEF.
A browser that never enables capture hands CEF a null handler, exactly as
the stub did, so nothing that embeds a page today changes.
The capture thread takes no lock the embedder can hold: packets go out
through a bounded try_send, their buffers come back through a try_recv
and are reused, a full queue drops the packet and counts it, and a lost
Started or Stopped is made up again from the stream epoch on the drain
side.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Measured on the Pixel 11 Pro XL (PowerVR): a pane swipe presented at
~84 fps on the 120 Hz panel, a vsync dropped every three to six frames.
simpleperf showed 22% of the CPU in the driver's shader compiler and 24%
in its render-target teardown: the Vulkan backend created a VkRenderPass
and a VkFramebuffer for every offscreen pass on every frame and destroyed
them after the fence, and on this driver each render pass compiles a
load-op shader. Offscreen draw render passes now live for the device
(keyed by formats and load/store ops) and framebuffers are cached per
render pass, attachment views and storage extent, invalidated through
texture retirement so they die after the frame that used them.
The app icons were re-tessellated from SVG every frame: one DrawSvg kept
one scale and the desk draws each icon at two or three sizes. A DrawSvg
keeps up to four meshes per device scale; the geometry pool defers frees
and releases them once per frame against the geometry ids the live draw
lists still name, so a retained draw call never sees its slot reused.
A font member whose resource can never load (the WM referenced Inter and
its other faces through `self:../../widgets/...`, unmapped in a package)
kept its family incomplete, and an incomplete family is redefined every
frame: the layout cache cleared, every label laid out again, the asset
reopened. Such a member drops out of its family once, logged, keyed on
the resource registry's generation so a resource that appears later is
asked for again. The WM names its fonts through `makepad_widgets:` and
reads the clock in-process on the UI thread instead of forking `date`
twice a second.
Android gains a `frame.cpu` trace (events, next-frame, draw and repaint
milliseconds per drawn frame) and a profileable manifest so simpleperf
can sample a release build. The dyn-pack tile proof tolerates the app's
own Dirty line after an engine rebuild.
After: SurfaceFlinger presents every swipe frame at 8.3 ms, the render
thread runs at ~45% instead of 85–97%, and the frame is paced by the GPU.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The contribution widened `StyleTween` to eight weights but left its two initialisers at
seven, so the window manager did not compile. It also declared `BlackOrange` second in
`DesktopStyle`, while the window manager reads the tween's weights by discriminant (1 is
macOS, 3 Windows 2000, 4 NeXTSTEP): every style after Omarchy would have driven the chrome
of the one before it. The new style is declared last, `ALL` keeps the order the sheets are
shown in, and `next()` walks `ALL` by place rather than by discriminant. An unused import
in a storybook story goes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Squashed from vjroger/makepad `storybook-pr` at 086d25452 (1,332 commits on top of
6f1e44649; his last commit restored every path outside the contribution to upstream).
- apps/storybook: every component organised, documented and previewed live.
- widgets: about eighty new widgets (accordion, alert, avatar, badge, breadcrumb, calendar,
card, carousel, chat, chip, colour, command palette, date and time pickers, dialog,
dropzone, floating action and panel, form, hamburger, line and radial menus, kanban,
masonry, menu, nav list, pagination, pill nav, popover, progress, property inspector,
range slider, rich text, select, spinner, table, tabs, tag field, timeline, toast,
toolbar, tour, transfer, tree, waveform, wheel picker and more); theme tokens and a
theme store, themes mixed by weight with a legibility check, a twelfth style sheet in
black and orange; the data grid gains row selection, drag and reorder, heading tips and
alignment; the glass button is a water lens; the portal list keeps the wheel it uses,
stands down from a press another control holds, can keep a row on screen and rule the
gap under a short list.
- platform: sweep locks and scroll blocks nest, `is_mouse_held_outside`, per-axis
scroll-handled flags, `next_frame_is_pending`, owner-scoped scroll unblocking, a
hands-off marker for the remote bridge, exploded-view projection and focus.
- draw: the interior distance of square-cornered boxes, a pointer shape, and
`turtle_ancestor_clip`.
- wm: the style tween carries an eighth weight for the new sheet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`cargo makepad android dyn-pack` stages the relocatable checkout,
cross-builds host + engine from it through the ordinary Android build,
proves every tile's on-device command against that target/, packs the
APK with the phone toolchain, the checkout and target/ as streamed LZ4
tar parts, and rehearses the phone's first tile open from the packed
APK; `dyn-rehearse` runs that last gate alone. This replaces the
Python and shell pipeline that lived outside the tree. The stage
directory is tool-owned, every cargo phase runs under one controlled
environment recorded in the target's marker, rustc runs through
cargo-makepad itself as the remapping wrapper, and the APK is renamed
into place only after signing and the rehearsal. The host package
names its engine and tiles in [package.metadata.makepad.dyn]. Only the
three /system/bin/sh templates that run on the phone stay shell.
libs/rmeta is the rustc metadata header reader apps/wm used, now shared
with cargo-makepad; libs/tar gains a streaming ustar writer with GNU
long names; the zip writer streams to any sink so a 900 MB APK never
sits in memory.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>