Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
067e9a61e8 | ||
|
|
1b43014abd | ||
| 7c2a5bf8c8 | |||
| ef9a55c2a2 | |||
| 1f03d91c5f |
@@ -8,13 +8,13 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
|
||||
## Available Features
|
||||
|
||||
- Native Iced desktop workspace with a localized native macOS menu and in-window Linux/Windows menu bar, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Every enabled control participates in Tab/Shift-Tab focus traversal and Enter/Space activation; holding Control+Option on macOS or Control+Alt elsewhere reveals direct shortcuts plus stable two/three-letter codes for visible generic controls, filters those codes as they are typed, and lets Escape cancel while Option/Alt alone remains available for character entry. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work.
|
||||
- Native Iced desktop workspace with a deep shared dark palette, a localized native macOS menu and in-window Linux/Windows menu bar, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Every enabled control participates in Tab/Shift-Tab focus traversal and Enter/Space activation; holding Control+Option on macOS or Control+Alt elsewhere reveals direct shortcuts plus stable two/three-letter codes for visible generic controls, filters those codes as they are typed, and lets Escape cancel while Option/Alt alone remains available for character entry. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work.
|
||||
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, cursor-preserving link and media insertion, live link/backlink graphs, media, and batch gallery-image import.
|
||||
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, and bounded process-local compiled Lua reuse with a fresh sandbox per invocation, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||
- SQLite and filesystem persistence with byte-canonical bDS2 frontmatter, media sidecars, metadata JSON, and OPML menus; rebuild; bidirectional metadata diff/repair including project categories and publishing preferences; stale post-path cleanup on republish; bDS2-compatible checksums and NFD slug generation; and FTS5 search.
|
||||
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
|
||||
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag and category autocomplete and duplicate-post review in the desktop workspace.
|
||||
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.
|
||||
- A localized Tags workspace manages tags and category settings; its category table includes the main language and every configured translation, whose titles are used by matching category archives and menu entries.
|
||||
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
||||
@@ -29,7 +29,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
|
||||
- SSH-agent-based SCP or rsync publishing.
|
||||
- Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
|
||||
- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor as soon as the draft exists without changing the current or restored sidebar state or waiting for on-device semantic indexing, and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol.
|
||||
- Site validation detects and repairs stale content at every generated post URL, alongside media and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor as soon as the draft exists without changing the current or restored sidebar state or waiting for on-device semantic indexing, and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol.
|
||||
|
||||
RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview.
|
||||
|
||||
|
||||
472
RELEASEBUILD.md
Normal file
472
RELEASEBUILD.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Portable Gitea Release Builds from a Linux ARM64 Runner
|
||||
|
||||
This document is a blueprint for producing native Rust application releases for
|
||||
Linux ARM64, Linux x64, macOS ARM64, macOS x64, and Windows x64 from a Gitea
|
||||
Actions runner whose host is Ubuntu ARM64. It records the design and the failure
|
||||
modes proven while preparing the RuDS 0.9.0 release.
|
||||
|
||||
The important distinction is that the release tooling is portable Rust, not
|
||||
that every application dependency is pure Rust. GTK and WebKitGTK are normal
|
||||
Linux system libraries and should be linked from the runner's Ubuntu packages.
|
||||
Apple bundles need portable construction because no macOS runner is available.
|
||||
|
||||
The implementation in this repository is split between:
|
||||
|
||||
- `.gitea/workflows/release.yml`: orchestration and target-specific build setup.
|
||||
- `.gitea/scripts/install-linux-build-dependencies.sh`: native ARM64 packages
|
||||
and the extracted x64 Ubuntu sysroot.
|
||||
- `.gitea/scripts/x86_64-linux-gnu-gcc-pic` and
|
||||
`.gitea/scripts/x86_64-linux-gnu-g++-pic`: cross-compiler wrappers.
|
||||
- `crates/bds-release`: release notes, Gitea API access, dependency downloads,
|
||||
archive creation, Apple bundle construction, signing, and upload.
|
||||
|
||||
## Resulting artifact matrix
|
||||
|
||||
Build CLI and desktop artifacts separately so users do not have to download a
|
||||
GUI application to obtain command-line tools. RuDS creates two assets per target:
|
||||
|
||||
| Target | CLI | Desktop |
|
||||
| --- | --- | --- |
|
||||
| macOS ARM64 | `.tar.gz` | `.dmg` containing an `.app` |
|
||||
| macOS x64 | `.tar.gz` | `.dmg` containing an `.app` |
|
||||
| Linux ARM64 | `.tar.gz` | `.tar.gz` |
|
||||
| Linux x64 | `.tar.gz` | `.tar.gz` |
|
||||
| Windows x64 | `.zip` | portable `.zip` |
|
||||
|
||||
Compile all final programs for a target in one Cargo invocation. In this
|
||||
repository those are `bds-ui`, `bds-cli`, and `bds-mcp`. This keeps their feature
|
||||
selection and shared runtime libraries consistent.
|
||||
|
||||
## Pipeline shape
|
||||
|
||||
Use a tag-triggered, draft-first pipeline:
|
||||
|
||||
1. Check out full history, test the release helper, generate notes, and create
|
||||
or update a draft Gitea release.
|
||||
2. Build and upload every platform into that release ID.
|
||||
3. Publish only after every build job succeeds.
|
||||
4. Query Gitea afterward and verify the public release, target commit, and asset
|
||||
manifest.
|
||||
|
||||
A minimal dependency graph is:
|
||||
|
||||
```text
|
||||
prepare draft
|
||||
|
|
||||
+-- Linux x64
|
||||
|
|
||||
+-- Linux ARM64
|
||||
+-- macOS ARM64 + x64
|
||||
+-- Windows x64
|
||||
|
|
||||
+-- publish
|
||||
```
|
||||
|
||||
RuDS deliberately runs the most failure-prone cross-link first. On a single
|
||||
runner all jobs serialize anyway, and failing early avoids spending hours on
|
||||
other targets first. With several independent runners, make all platform jobs
|
||||
depend only on preparation and let them run in parallel.
|
||||
|
||||
The draft is the transaction boundary. Never make a release public before all
|
||||
assets exist. The final publisher should ideally fetch the draft and compare
|
||||
the exact expected filenames and non-zero sizes before setting `draft: false`.
|
||||
RuDS currently relies on job dependencies plus strict per-package input checks;
|
||||
adding an explicit final manifest check would make that boundary stronger.
|
||||
|
||||
## Gitea runner and permissions
|
||||
|
||||
Register a label that describes the real machine, for example
|
||||
`linux-arm64:host`, and use `runs-on: linux-arm64`. Do not treat a name such as
|
||||
`ubuntu-latest` as an implicit x64 machine. Gitea runner labels have the form
|
||||
`label[:schema[:args]]`.
|
||||
|
||||
The runner needs:
|
||||
|
||||
- Enough disk for Cargo registries, several target trees, cross SDKs, sysroots,
|
||||
and container layers.
|
||||
- Outbound HTTPS access to Rust distribution servers, crates.io, Gitea, NuGet,
|
||||
GitHub release assets, and the configured container registry.
|
||||
- Passwordless access to install Ubuntu build packages, or a prebuilt runner
|
||||
image containing them.
|
||||
- Container support for the macOS and Windows build images.
|
||||
- A URL by which build containers can reach Gitea. `localhost` inside a
|
||||
container is the container itself, not the runner host.
|
||||
|
||||
Container images must publish an ARM64 image even when the target they build is
|
||||
x64. Pin images by digest, not a floating tag. Also pin the Rust toolchain and
|
||||
always build with `--locked`.
|
||||
|
||||
Grant the workflow only the permissions it needs: repository contents read,
|
||||
issues read if release notes inspect issues, and releases write. Gitea clamps
|
||||
workflow permissions to repository or owner limits. If the built-in
|
||||
`secrets.GITEA_TOKEN` cannot write releases on the deployed Gitea version, use a
|
||||
repository secret containing a narrowly scoped token.
|
||||
|
||||
Gitea Actions resembles GitHub Actions but is not identical. Validate contexts,
|
||||
permissions, output files, and action compatibility against the deployed Gitea
|
||||
and `act_runner` versions. Non-fully-qualified actions may be downloaded from
|
||||
GitHub depending on `DEFAULT_ACTIONS_URL`; mirror or fully qualify them on
|
||||
restricted installations.
|
||||
|
||||
Pass the draft release ID through a job output. Gitea supports the familiar
|
||||
`key=value` step-output file convention; this repository writes to the
|
||||
`GITHUB_OUTPUT` compatibility variable and maps that step output to a job
|
||||
output. Do not scrape an ID from human-readable command output.
|
||||
|
||||
Specialized build containers may not run JavaScript-based checkout actions
|
||||
reliably. RuDS performs an authenticated, depth-one `git fetch` of the exact tag
|
||||
inside those containers. Fetch the tag ref explicitly and check out
|
||||
`FETCH_HEAD`; do not build a mutable branch tip.
|
||||
|
||||
## Linux desktop builds
|
||||
|
||||
There is no architectural reason to exclude the GTK/WebKit desktop application
|
||||
from Linux artifacts. The native ARM64 build is ordinary compilation on the
|
||||
ARM64 Ubuntu runner. Install:
|
||||
|
||||
```text
|
||||
build-essential
|
||||
cmake
|
||||
libgtk-3-dev
|
||||
libwebkit2gtk-4.1-dev
|
||||
libxdo-dev
|
||||
pkg-config
|
||||
```
|
||||
|
||||
The resulting application dynamically links the distro GTK/WebKit stack. Do
|
||||
not put the entire GTK/WebKit dependency closure into the application archive.
|
||||
Bundle application-owned shared libraries, dynamically linked Rust standard
|
||||
libraries, and explicitly redistributable runtimes; document the required
|
||||
Linux runtime packages for users.
|
||||
|
||||
Build on the oldest distro baseline that the release promises to support.
|
||||
Using a rolling `ubuntu-latest` silently raises the minimum glibc and WebKitGTK
|
||||
versions when the runner image changes. A fixed Ubuntu release or container is
|
||||
more reproducible.
|
||||
|
||||
Add an `$ORIGIN` runtime search path so executables find shared libraries placed
|
||||
beside them:
|
||||
|
||||
```text
|
||||
-C link-arg=-Wl,-rpath,$ORIGIN
|
||||
```
|
||||
|
||||
### Cross-linking Linux x64 on ARM64
|
||||
|
||||
Rust cross-compilation is the easy part. Native dependencies selected through
|
||||
`pkg-config`, C/C++ build scripts, the linker, and the x64 libc are the parts
|
||||
that require deliberate configuration.
|
||||
|
||||
On an Ubuntu ARM64 host:
|
||||
|
||||
1. Restrict the existing Ubuntu deb822 sources to `arm64`.
|
||||
2. Add `amd64` as a foreign architecture and add matching amd64 archive and
|
||||
security sources for the same Ubuntu codename.
|
||||
3. Install native host tools such as `gcc-x86-64-linux-gnu`,
|
||||
`g++-x86-64-linux-gnu`, `cmake`, and `pkg-config`.
|
||||
4. Download the GTK, WebKitGTK, and xdo amd64 development packages and their
|
||||
dependencies with APT's `--download-only` mode.
|
||||
5. Extract the `.deb` files with `dpkg-deb --extract` into an isolated sysroot,
|
||||
such as `/opt/ruds-x64-sysroot`.
|
||||
6. Point Cargo, the GNU cross-linker, and `pkg-config` exclusively at that
|
||||
sysroot.
|
||||
|
||||
Do not normally install the foreign development packages on the ARM64 host.
|
||||
Some packages contain target-architecture helper executables or maintainer
|
||||
scripts; package installation can attempt to execute x64 programs on ARM64.
|
||||
Downloading and extracting produces the headers, libraries, and metadata needed
|
||||
for linking without running target code.
|
||||
|
||||
The essential environment is:
|
||||
|
||||
```text
|
||||
AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc
|
||||
PKG_CONFIG_ALLOW_CROSS=1
|
||||
PKG_CONFIG_SYSROOT_DIR=/opt/ruds-x64-sysroot
|
||||
PKG_CONFIG_LIBDIR=/opt/ruds-x64-sysroot/usr/lib/x86_64-linux-gnu/pkgconfig:/opt/ruds-x64-sysroot/usr/lib/pkgconfig:/opt/ruds-x64-sysroot/usr/share/pkgconfig
|
||||
```
|
||||
|
||||
Pass `--sysroot` to the linker, add the target library directories with Cargo
|
||||
`-L native=...`, and retain the `$ORIGIN` rpath. Do not leave host pkg-config
|
||||
paths in the search list: that can produce an ARM64/x64 mixture which may fail
|
||||
late or, worse, appear to link successfully.
|
||||
|
||||
Ubuntu libc development linker scripts may refer to absolute legacy paths such
|
||||
as `/lib/x86_64-linux-gnu`. Those paths are interpreted inside the sysroot but
|
||||
may not exist in an extracted modern Ubuntu package layout. Materialize the
|
||||
required libc, libm, libmvec, and loader paths within the sysroot. Do not create
|
||||
host-level x64 symlinks.
|
||||
|
||||
Architecture-independent `.pc` files may already be installed on the host, so
|
||||
APT may not download them again for amd64. They contain metadata rather than
|
||||
target executables and can be copied into the sysroot; `PKG_CONFIG_SYSROOT_DIR`
|
||||
then redirects their paths.
|
||||
|
||||
Some native Rust dependencies build static objects that are later linked into
|
||||
application shared libraries. They need position-independent code. Compiler
|
||||
wrappers should append `-fPIC` as the final flag so it overrides earlier flags.
|
||||
Target-specific Cargo environment names can contain hyphens and therefore are
|
||||
not valid shell assignment identifiers. Set them through `env`, for example:
|
||||
|
||||
```sh
|
||||
env \
|
||||
"CC_x86_64-unknown-linux-gnu=$PWD/.gitea/scripts/x86_64-linux-gnu-gcc-pic" \
|
||||
"CXX_x86_64-unknown-linux-gnu=$PWD/.gitea/scripts/x86_64-linux-gnu-g++-pic" \
|
||||
cargo build --release --locked --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
Audit every `-sys` crate for pkg-config overrides. For RuDS,
|
||||
`ZSTD_SYS_USE_PKG_CONFIG` must be unset in the x64 build so zstd does not select
|
||||
an incompatible host library.
|
||||
|
||||
## macOS without a macOS runner
|
||||
|
||||
Use `cargo-zigbuild` in a digest-pinned ARM64-compatible container to compile
|
||||
the `aarch64-apple-darwin` and `x86_64-apple-darwin` targets. The container must
|
||||
contain a legally usable Apple SDK. Review the SDK and redistribution licenses;
|
||||
portable tooling does not remove those obligations.
|
||||
|
||||
RuDS supplies these linker requirements:
|
||||
|
||||
- Both `@executable_path` and `@executable_path/../Resources` rpaths. The first
|
||||
serves the flat CLI archive and the second serves the `.app` layout.
|
||||
- Header padding for later Mach-O load-command rewriting.
|
||||
- The chosen minimum macOS deployment version.
|
||||
- `-Wl,-undefined,dynamic_lookup` where Apple symbols are intentionally resolved
|
||||
at runtime.
|
||||
|
||||
Zig's Darwin linker does not implement every `ld64` option. In particular,
|
||||
symbol-specific `-U` handling was not usable here. Broad
|
||||
`-undefined,dynamic_lookup` works when late-bound framework symbols are truly
|
||||
required, but it can also mask accidental unresolved symbols; use it only with
|
||||
dependency inspection and runtime testing.
|
||||
|
||||
The Zig Apple link path expected a compiler runtime archive alias. A valid empty
|
||||
`libclang_rt.osx.a` archive (`!<arch>\n`) in an explicit native library directory
|
||||
satisfied that lookup.
|
||||
|
||||
### Intel macOS and version-sensitive native runtimes
|
||||
|
||||
Do not assume that the latest machine-learning runtime publishes every Apple
|
||||
architecture. The ONNX Runtime release used by newer Rust crates no longer had
|
||||
an Intel macOS binary. RuDS keeps a compatible dependency line:
|
||||
|
||||
```text
|
||||
fastembed 5.12.0
|
||||
ort/ort-sys 2.0.0-rc.11
|
||||
ONNX Runtime 1.23.2 for macOS x64
|
||||
```
|
||||
|
||||
The Rust release helper downloads Microsoft's x64 archive, verifies its pinned
|
||||
SHA-256 digest, extracts the versioned dylib and license, and creates an
|
||||
unversioned linker copy. With `ort-sys` rc.11, the working build variables are:
|
||||
|
||||
```text
|
||||
ORT_LIB_LOCATION=<directory-containing-libonnxruntime.dylib>
|
||||
ORT_PREFER_DYNAMIC_LINK=1
|
||||
```
|
||||
|
||||
`ORT_LIB_PATH`, documented or accepted by other versions, is ignored by this
|
||||
specific prerelease. When a native dependency behaves unexpectedly, read the
|
||||
build script for the exact locked crate version rather than documentation for a
|
||||
newer release.
|
||||
|
||||
### Pure-Rust Apple packaging
|
||||
|
||||
Build the application bundle and DMG with Rust crates rather than `hdiutil`,
|
||||
`codesign`, shell archive tricks, or a simulated macOS environment. RuDS uses
|
||||
Rust libraries to:
|
||||
|
||||
1. Create `Contents/MacOS`, `Contents/Resources`, `Info.plist`, the icon, bundle
|
||||
metadata, and URL handlers.
|
||||
2. Put executables in `Contents/MacOS` and dylibs/licenses in Resources.
|
||||
3. Parse 64-bit little-endian Mach-O load commands and rewrite application
|
||||
dylib IDs and references to `@rpath/<basename>`.
|
||||
4. Sign after all binary mutations.
|
||||
5. Construct and sign the DMG.
|
||||
|
||||
Mach-O load commands have fixed allocated space. Reserve header padding at link
|
||||
time or a longer rewritten install name will not fit. Rewrite every applicable
|
||||
load command, including weak, re-exported, lazy, and upward dylib loads, and
|
||||
never attempt to parse licenses or other resource files as Mach-O binaries.
|
||||
|
||||
The current output is ad-hoc signed. Developer ID signing and Apple notarization
|
||||
need Apple-issued credentials and a supported submission path; a Linux-only
|
||||
pipeline cannot claim notarized output merely because it can construct and
|
||||
ad-hoc-sign a valid bundle.
|
||||
|
||||
## Windows x64 from Linux ARM64
|
||||
|
||||
Use `cargo-xwin` in a digest-pinned ARM64-compatible container. It provides the
|
||||
Windows MSVC SDK/import-library environment without requiring a Windows host.
|
||||
Install the Rust `x86_64-pc-windows-msvc` target and NASM when native assembly
|
||||
dependencies require it, then populate the xwin cache before looking up SDK
|
||||
libraries.
|
||||
|
||||
RuDS's DirectML-enabled build needs:
|
||||
|
||||
- Microsoft DirectML 1.15.4 downloaded from NuGet and verified by SHA-256.
|
||||
- `DirectML.lib` for linking, plus `DirectML.dll` and its license in the final
|
||||
package.
|
||||
- A correctly cased `PathCch.lib` alias copied from the xwin SDK, because some
|
||||
build tooling expects that spelling.
|
||||
- The target release directory on Cargo's native library search path.
|
||||
|
||||
Do not force `target-feature=+crt-static` merely to make the archive look more
|
||||
self-contained. Windows system and SDK dependencies may require the dynamic
|
||||
CRT, and forcing static CRT can produce incompatible link combinations. Bundle
|
||||
the redistributable application runtime DLLs that are actually required and
|
||||
retain their licenses. RuDS statically links ONNX Runtime on Windows but ships
|
||||
DirectML dynamically.
|
||||
|
||||
Portable ZIPs are the simplest cross-host Windows result. MSI, MSIX, or signed
|
||||
installer production introduces additional tooling, signing certificates, and
|
||||
Windows policy requirements and should be treated as separate scope.
|
||||
|
||||
## Packaging rules
|
||||
|
||||
Keep packaging in a small host-native Rust binary. `bds-release` runs on the
|
||||
ARM64 builder even while it packages foreign target files. It uses Rust for
|
||||
HTTP, hashing, tar/gzip, ZIP, plist, ICNS, Apple bundle/DMG construction, Mach-O
|
||||
mutation, signing, and Gitea API calls.
|
||||
|
||||
For each target:
|
||||
|
||||
- Require every expected executable and application shared library before
|
||||
creating an archive.
|
||||
- Copy dynamic Rust standard libraries from
|
||||
`rustc --target <target> --print target-libdir` when the project links Rust's
|
||||
standard library dynamically.
|
||||
- Include application-owned shared libraries and required redistributable
|
||||
third-party runtimes with license files.
|
||||
- Give archives deterministic, tag-derived names and a single top-level
|
||||
directory.
|
||||
- Reject unsafe tags used as filenames and refuse to overwrite existing output.
|
||||
- Stream large uploads with a content length rather than loading them into RAM.
|
||||
- Retry network failures, HTTP 429, and server errors with bounded backoff.
|
||||
- Verify every downloaded native dependency with a pinned cryptographic digest.
|
||||
|
||||
Packaging must follow the target's runtime model. Linux should use the distro's
|
||||
GTK/WebKit libraries; macOS `.app` bundles should carry their private dylibs in
|
||||
Resources with corrected install names; Windows packages should carry approved
|
||||
redistributable DLLs. “Put every shared library beside the executable” is not a
|
||||
portable policy.
|
||||
|
||||
## Release creation and rebuilding
|
||||
|
||||
The preparation job uses full Git history to locate the previous tag, reads
|
||||
issue numbers from commit messages, fetches closed issues through Gitea's API,
|
||||
and creates categorized release notes. It writes the draft release ID as a step
|
||||
output so every build uploads to the same release.
|
||||
|
||||
Rebuilding an existing published tag is deliberately explicit. In RuDS,
|
||||
`REBUILD_PUBLISHED_RELEASE=true` changes the release back to a draft, deletes
|
||||
its assets, regenerates its metadata, and uploads a consistent new set. This is
|
||||
destructive and temporarily removes the public release. Prefer a new immutable
|
||||
tag unless repairing an existing release is intentional. Rerunning a failed
|
||||
draft also clears its partial asset set before rebuilding, which prevents stale
|
||||
and new files from being mixed.
|
||||
|
||||
Before pushing a release tag:
|
||||
|
||||
1. Update the workspace version, lockfile, README, and any visible version
|
||||
metadata together.
|
||||
2. Run formatting, the full workspace tests, and relevant release-helper tests.
|
||||
3. Build locally for the host where practical.
|
||||
4. Commit and push the release commit to the default branch.
|
||||
5. Create an annotated tag at that exact commit, then push the tag.
|
||||
|
||||
After the workflow finishes, verify through the Gitea API or `tea`:
|
||||
|
||||
- Every job is successful rather than merely the workflow appearing complete.
|
||||
- The tag and its peeled commit match the intended default-branch commit.
|
||||
- The release is public (`draft: false`) and has the expected prerelease flag.
|
||||
- The release targets the exact tagged commit.
|
||||
- The expected asset names are present exactly once and have non-zero sizes.
|
||||
- The repository worktree is clean and local/remote refs agree.
|
||||
|
||||
Useful Gitea API endpoints are:
|
||||
|
||||
```text
|
||||
GET /repos/{owner}/{repo}/actions/runs
|
||||
GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs
|
||||
GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs
|
||||
GET /repos/{owner}/{repo}/releases/tags/{tag}
|
||||
```
|
||||
|
||||
Job logs can be very large. Download or query them once, then filter locally for
|
||||
the failing step and nearby compiler/linker diagnostics instead of repeatedly
|
||||
printing the entire log.
|
||||
|
||||
## Common failures
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --- | --- | --- |
|
||||
| `Exec format error` during x64 dependency installation | A foreign package's executable or maintainer script ran on ARM64 | Download packages only and extract them into a sysroot |
|
||||
| Linker reports wrong ELF architecture | Host libraries leaked through pkg-config or a build script | Restrict `PKG_CONFIG_LIBDIR`, set its sysroot, and audit `-sys` crate overrides |
|
||||
| Relocation errors while creating a `.so` | A C/C++ static object was built without PIC | Append `-fPIC` in target compiler wrappers |
|
||||
| libc or loader missing despite downloaded packages | Ubuntu linker scripts contain absolute legacy paths | Materialize those target paths inside the sysroot |
|
||||
| Linux binary starts but cannot find project `.so` files | No relative runtime search path | Link with an `$ORIGIN` rpath and package the libraries beside the binary |
|
||||
| macOS link rejects `-U` | Zig's Darwin linker differs from Apple `ld64` | Use supported options; use dynamic lookup only when justified |
|
||||
| Mach-O install-name rewrite does not fit | No load-command header padding | Add header padding before linking, then mutate before signing |
|
||||
| Intel macOS ORT download fails | The selected upstream release dropped x64 macOS | Pin a compatible crate/runtime pair and supply a verified dylib |
|
||||
| `ort-sys` ignores a supplied runtime | Environment variable belongs to another crate version | Inspect the exact locked crate build script; rc.11 uses `ORT_LIB_LOCATION` |
|
||||
| Windows library is “missing” despite SDK contents | Import-library case/name differs from build-script expectation | Add an explicit linker alias such as `PathCch.lib` |
|
||||
| Windows CRT link conflicts | Static CRT was forced across incompatible dependencies | Use the target's normal dynamic CRT unless the full graph supports static CRT |
|
||||
| Checkout fails only inside a build container | The image cannot execute the checkout action or cannot reach Gitea | Fetch the exact tag with authenticated Git in the container |
|
||||
| Release is public but incomplete | Publication happened before asset completion | Create a draft first and publish only behind all build jobs and manifest checks |
|
||||
| A release takes hours | Cold Rust/native builds on one physical ARM64 runner serialize | Add real runners, carefully cache immutable inputs, or prebake dependencies |
|
||||
|
||||
## Performance and reproducibility
|
||||
|
||||
A cold five-target build with GTK/WebKit, ONNX Runtime, and ML dependencies can
|
||||
take about three hours on a single ARM64 runner. The workflow itself is simple;
|
||||
compilation and native dependency setup dominate the time.
|
||||
|
||||
Safe ways to reduce it include:
|
||||
|
||||
- Add separate runners with the same label, or platform-specific labels, so
|
||||
independent target jobs truly run concurrently.
|
||||
- Prebuild a versioned runner/container image containing system dependencies and
|
||||
the Linux x64 sysroot.
|
||||
- Cache Cargo registry, Git checkouts, target artifacts, Rust toolchains, and
|
||||
cross-SDK caches with keys including the lockfile, Rust version, target,
|
||||
feature set, distro version, and relevant build scripts.
|
||||
- Avoid concurrent memory-heavy jobs on one physical host even if several
|
||||
logical runners are registered there.
|
||||
|
||||
Prefer a slow clean build over a fast cache that can mix architectures or stale
|
||||
native libraries. Keep caches disabled until their keys and invalidation rules
|
||||
are demonstrably correct.
|
||||
|
||||
## Validation depth
|
||||
|
||||
Cross-built programs cannot be fully exercised on the ARM64 host, but artifacts
|
||||
should still receive structural checks:
|
||||
|
||||
- ELF: inspect architecture, `DT_NEEDED`, rpath, and unexpected build-host paths
|
||||
with `file` and `readelf`.
|
||||
- Mach-O: inspect architectures, load commands, bundle layout, plist, signatures,
|
||||
and DMG contents with parsers that run on Linux.
|
||||
- PE: inspect machine type and imports with LLVM or MinGW object tools.
|
||||
- Archives: extract into temporary directories, compare the manifest, and check
|
||||
executable modes and licenses.
|
||||
|
||||
Then smoke-test on real target systems. QEMU or Wine can catch basic loader
|
||||
failures but is not authoritative for GTK/WebKit behavior, macOS signing and
|
||||
Gatekeeper, or Windows GPU/DirectML behavior.
|
||||
|
||||
## References
|
||||
|
||||
- [Gitea Actions](https://docs.gitea.com/usage/actions)
|
||||
- [Gitea Actions design and runner labels](https://docs.gitea.com/usage/actions/design)
|
||||
- [Gitea Actions variables](https://docs.gitea.com/usage/actions/actions-variables)
|
||||
- [Gitea API](https://docs.gitea.com/api/1.24/)
|
||||
- [cargo-zigbuild](https://github.com/rust-cross/cargo-zigbuild)
|
||||
- [cargo-xwin](https://github.com/rust-cross/cargo-xwin)
|
||||
|
||||
Treat the checked-in workflow and Rust helper as the executable source of truth.
|
||||
This document explains why their unusual-looking pieces exist so that future
|
||||
agents do not remove them as apparent complexity and rediscover the same target,
|
||||
linker, packaging, and release-state failures.
|
||||
30
crates/bds-cli/build.rs
Normal file
30
crates/bds-cli/build.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use std::{env, process::Command};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=RUSTC");
|
||||
|
||||
if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("linux")
|
||||
|| env::var("PROFILE").as_deref() != Ok("debug")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
|
||||
let target = env::var("TARGET").expect("Cargo must provide the build target");
|
||||
let output = Command::new(rustc)
|
||||
.args(["--target", &target, "--print", "target-libdir"])
|
||||
.output()
|
||||
.expect("query Rust target library directory");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"rustc --print target-libdir failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let target_libdir =
|
||||
String::from_utf8(output.stdout).expect("Rust target library directory must be UTF-8");
|
||||
|
||||
println!(
|
||||
"cargo:rustc-link-arg-bin=bds-cli=-Wl,-rpath,{}",
|
||||
target_libdir.trim()
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,23 @@ fn cli(home: &std::path::Path, args: &[&str]) -> std::process::Output {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn built_cli_runs_without_cargo_loader_paths() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_bds-cli"))
|
||||
.env_remove("LD_LIBRARY_PATH")
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("rebuild"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_exit_codes_help_and_shared_state_roundtrip() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::db::queries;
|
||||
use crate::engine::generation::has_published_snapshot;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Post;
|
||||
use crate::render::{build_canonical_post_path, build_site_route_manifest};
|
||||
use crate::render::{build_post_route_paths, build_site_route_manifest};
|
||||
|
||||
const MTIME_GRANULARITY_TOLERANCE_MS: i64 = 1_000;
|
||||
|
||||
@@ -145,24 +145,23 @@ fn stale_post_paths(
|
||||
if language != main_language && post.do_not_translate {
|
||||
continue;
|
||||
}
|
||||
let relative_path = format!(
|
||||
"{}/index.html",
|
||||
build_canonical_post_path(post, language, main_language).trim_start_matches('/')
|
||||
);
|
||||
if !expected.contains(&relative_path) || !actual.contains(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
let Some(output_modified) = modified_ms(&output_dir.join(&relative_path)) else {
|
||||
continue;
|
||||
};
|
||||
let effective_generated = output_modified.max(
|
||||
generated_at
|
||||
.get(&relative_path)
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if source_modified > effective_generated + MTIME_GRANULARITY_TOLERANCE_MS {
|
||||
stale.push(relative_path);
|
||||
for url_path in build_post_route_paths(post, language, main_language) {
|
||||
let relative_path = format!("{}/index.html", url_path.trim_start_matches('/'));
|
||||
if !expected.contains(&relative_path) || !actual.contains(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
let Some(output_modified) = modified_ms(&output_dir.join(&relative_path)) else {
|
||||
continue;
|
||||
};
|
||||
let effective_generated = output_modified.max(
|
||||
generated_at
|
||||
.get(&relative_path)
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if source_modified > effective_generated + MTIME_GRANULARITY_TOLERANCE_MS {
|
||||
stale.push(relative_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ pub use generation::{
|
||||
pub use markdown::render_markdown_to_html;
|
||||
pub(crate) use page_renderer::validate_liquid_template_syntax;
|
||||
pub use page_renderer::{RenderError, render_liquid_template};
|
||||
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
|
||||
pub(crate) use routes::{
|
||||
PostLanguageVariant, blog_page_title, build_post_route_paths, select_post_language_variant,
|
||||
};
|
||||
pub use routes::{
|
||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||
render_starter_list_page_with_media_map, render_starter_single_post_page,
|
||||
|
||||
@@ -161,6 +161,22 @@ pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &st
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_post_route_paths(
|
||||
post: &Post,
|
||||
language: &str,
|
||||
main_language: &str,
|
||||
) -> Vec<String> {
|
||||
let mut paths = vec![build_canonical_post_path(post, language, main_language)];
|
||||
if post.categories.iter().any(|category| category == "page") {
|
||||
paths.push(if language == main_language {
|
||||
format!("/{}", post.slug)
|
||||
} else {
|
||||
format!("/{language}/{}", post.slug)
|
||||
});
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub fn render_starter_single_post_page(
|
||||
post: &Post,
|
||||
body_markdown: &str,
|
||||
|
||||
@@ -19,7 +19,8 @@ use crate::model::{
|
||||
};
|
||||
use crate::render::{
|
||||
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title,
|
||||
build_canonical_post_path, resolve_post_template, select_post_language_variant,
|
||||
build_canonical_post_path, build_post_route_paths, resolve_post_template,
|
||||
select_post_language_variant,
|
||||
};
|
||||
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
|
||||
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
|
||||
@@ -203,26 +204,16 @@ pub fn build_site_route_manifest(
|
||||
);
|
||||
|
||||
for record in posts {
|
||||
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
|
||||
let mut paths = vec![canonical_path];
|
||||
if record
|
||||
.post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|category| category == "page")
|
||||
{
|
||||
paths.push(if language == main_language {
|
||||
format!("/{}", record.post.slug)
|
||||
} else {
|
||||
format!("/{language}/{}", record.post.slug)
|
||||
});
|
||||
}
|
||||
manifest.extend(paths.into_iter().map(|url_path| SitePage {
|
||||
language: language.clone(),
|
||||
relative_path: format!("{}/index.html", url_path.trim_start_matches('/')),
|
||||
url_path,
|
||||
html: String::new(),
|
||||
}));
|
||||
manifest.extend(
|
||||
build_post_route_paths(&record.post, &language, &main_language)
|
||||
.into_iter()
|
||||
.map(|url_path| SitePage {
|
||||
language: language.clone(),
|
||||
relative_path: format!("{}/index.html", url_path.trim_start_matches('/')),
|
||||
url_path,
|
||||
html: String::new(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,23 +512,12 @@ pub fn build_site_render_artifacts_from_context(
|
||||
let mut single_routes = Vec::new();
|
||||
for record in localized_posts {
|
||||
let canonical_path = build_canonical_post_path(&record.post, language, main_language);
|
||||
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
|
||||
if record
|
||||
.post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|category| category == "page")
|
||||
{
|
||||
post_paths.push((
|
||||
if language == main_language {
|
||||
format!("/{}", record.post.slug)
|
||||
} else {
|
||||
format!("/{language}/{}", record.post.slug)
|
||||
},
|
||||
GenerationSection::Core,
|
||||
));
|
||||
}
|
||||
for (url_path, route_section) in post_paths {
|
||||
for url_path in build_post_route_paths(&record.post, language, main_language) {
|
||||
let route_section = if url_path == canonical_path {
|
||||
GenerationSection::Single
|
||||
} else {
|
||||
GenerationSection::Core
|
||||
};
|
||||
let relative_path = format!("{}/index.html", url_path.trim_start_matches('/'));
|
||||
artifacts.route_manifest.push(SitePage {
|
||||
language: language.clone(),
|
||||
|
||||
@@ -1023,6 +1023,86 @@ fn site_validation_detects_post_sources_newer_than_generated_routes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_validation_repairs_every_stale_route_for_page_posts() {
|
||||
use std::fs::{File, FileTimes};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let mut post = make_post("wiki", 1_710_000_000_000);
|
||||
post.categories = vec!["page".into(), "wiki".into()];
|
||||
write_published_snapshot(&dir, &mut post, "Old wiki body");
|
||||
bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap();
|
||||
let old_source = PublishedPostSource {
|
||||
post: post.clone(),
|
||||
body_markdown: "Old wiki body".into(),
|
||||
};
|
||||
|
||||
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &[old_source], "en").unwrap();
|
||||
write_published_snapshot(&dir, &mut post, "Updated wiki body");
|
||||
|
||||
let canonical_path = "2024/03/09/wiki/index.html";
|
||||
let flat_path = "wiki/index.html";
|
||||
for path in [canonical_path, flat_path] {
|
||||
File::options()
|
||||
.write(true)
|
||||
.open(dir.path().join(path))
|
||||
.unwrap()
|
||||
.set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut hash = bds_core::db::queries::generated_file_hash::get_generated_file_hash(
|
||||
db.conn(),
|
||||
"p1",
|
||||
path,
|
||||
)
|
||||
.unwrap();
|
||||
hash.updated_at = 10_000;
|
||||
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(db.conn(), &hash)
|
||||
.unwrap();
|
||||
}
|
||||
File::options()
|
||||
.write(true)
|
||||
.open(dir.path().join(&post.file_path))
|
||||
.unwrap()
|
||||
.set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(20)))
|
||||
.unwrap();
|
||||
|
||||
let validation = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert_eq!(
|
||||
validation.stale_pages,
|
||||
vec![canonical_path.to_string(), flat_path.to_string()]
|
||||
);
|
||||
|
||||
let updated_source = load_published_post_source(dir.path(), post)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let sections = sections_from_validation_report(&validation, &metadata);
|
||||
apply_validation_sections(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&metadata,
|
||||
&[updated_source],
|
||||
&validation,
|
||||
§ions,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
std::fs::read_to_string(dir.path().join(canonical_path))
|
||||
.unwrap()
|
||||
.contains("Updated wiki body")
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_to_string(dir.path().join(flat_path))
|
||||
.unwrap()
|
||||
.contains("Updated wiki body")
|
||||
);
|
||||
let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
assert!(repaired.stale_pages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn site_validation_refreshes_sitemap_without_rendering_pages() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
@@ -82,15 +82,14 @@ fn prepare_macos_x64_runtime_from_archive(
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let path = entry.path()?;
|
||||
let destination = if path.ends_with(format!(
|
||||
"lib/libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib"
|
||||
)) {
|
||||
Some(format!("libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib"))
|
||||
} else if path.ends_with("LICENSE") {
|
||||
Some("ONNXRuntime-LICENSE.txt".to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let destination =
|
||||
if path.ends_with(format!("lib/libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib")) {
|
||||
Some(format!("libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib"))
|
||||
} else if path.ends_with("LICENSE") {
|
||||
Some("ONNXRuntime-LICENSE.txt".to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(destination) = destination {
|
||||
io::copy(&mut entry, &mut File::create(directory.join(destination))?)?;
|
||||
}
|
||||
@@ -335,9 +334,7 @@ fn runtime_files(
|
||||
.filter_map(|name| name.into_string().ok())
|
||||
.filter(|name| name.starts_with("libonnxruntime") && name.contains(".dylib"))
|
||||
.collect::<Vec<_>>();
|
||||
let has_versioned = names
|
||||
.iter()
|
||||
.any(|name| name != "libonnxruntime.dylib");
|
||||
let has_versioned = names.iter().any(|name| name != "libonnxruntime.dylib");
|
||||
if has_versioned {
|
||||
names.retain(|name| name != "libonnxruntime.dylib");
|
||||
}
|
||||
|
||||
@@ -6949,6 +6949,27 @@ impl BdsApp {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn ensure_post_editor_category(&mut self, post_id: &str, name: &str) -> Task<Message> {
|
||||
let (Some(db), Some(data_dir)) = (self.db.as_ref(), self.data_dir.as_ref()) else {
|
||||
return Task::none();
|
||||
};
|
||||
let Ok(post) = bds_core::db::queries::post::get_post_by_id(db.conn(), post_id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let categories = engine::meta::read_categories_json(data_dir).unwrap_or_default();
|
||||
if !categories
|
||||
.iter()
|
||||
.any(|category| category.eq_ignore_ascii_case(name))
|
||||
&& let Err(error) =
|
||||
engine::meta::add_category(db.conn(), data_dir, &post.project_id, name)
|
||||
{
|
||||
self.notify_operation_failed("editor.categories", error);
|
||||
return Task::none();
|
||||
}
|
||||
self.refresh_post_editor_category_options();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||
let (title, insertion_point) = match self.post_editors.get(post_id) {
|
||||
Some(state) => (state.title.clone(), state.insertion_point()),
|
||||
@@ -8768,6 +8789,7 @@ impl BdsApp {
|
||||
state.new_category_name.clear();
|
||||
}
|
||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||
self.refresh_post_editor_category_options();
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
Err(e) => self.notify_operation_failed("common.save", e),
|
||||
@@ -8853,6 +8875,7 @@ impl BdsApp {
|
||||
Ok(()) => {
|
||||
self.settings_state = Some(self.hydrate_settings_state());
|
||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||
self.refresh_post_editor_category_options();
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
Err(e) => self.notify_operation_failed("common.save", e),
|
||||
@@ -8894,6 +8917,7 @@ impl BdsApp {
|
||||
Ok(()) => {
|
||||
self.settings_state = Some(self.hydrate_settings_state());
|
||||
self.dashboard_state = Some(self.hydrate_dashboard_state());
|
||||
self.refresh_post_editor_category_options();
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
Err(e) => self.notify_operation_failed("common.save", e),
|
||||
@@ -9566,6 +9590,20 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn project_category_names(&self) -> Vec<String> {
|
||||
self.data_dir
|
||||
.as_deref()
|
||||
.and_then(|path| engine::meta::read_categories_json(path).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn refresh_post_editor_category_options(&mut self) {
|
||||
let names = self.project_category_names();
|
||||
for editor in self.post_editors.values_mut() {
|
||||
editor.available_categories.clone_from(&names);
|
||||
}
|
||||
}
|
||||
|
||||
fn post_translations_for_editor(&self, post: &Post) -> Vec<PostTranslation> {
|
||||
let Some(db) = &self.db else {
|
||||
return Vec::new();
|
||||
@@ -9635,6 +9673,7 @@ impl BdsApp {
|
||||
linked_media,
|
||||
);
|
||||
editor.available_tags = self.project_tag_names(&post.project_id);
|
||||
editor.available_categories = self.project_category_names();
|
||||
self.post_editors.insert(post.id.clone(), editor);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -13744,6 +13783,64 @@ mod tests {
|
||||
assert!(portable.contains("New Tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_editor_suggests_and_persists_project_categories() {
|
||||
let (db, project, tmp) = setup();
|
||||
let edited = post::create_post(
|
||||
db.conn(),
|
||||
tmp.path(),
|
||||
&project.id,
|
||||
"Edited",
|
||||
Some("Body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = make_app(db, project, &tmp);
|
||||
let _ = app.open_tab(Tab {
|
||||
id: edited.id.clone(),
|
||||
tab_type: TabType::Post,
|
||||
title: edited.title,
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
app.post_editors[&edited.id].available_categories,
|
||||
vec!["article", "aside", "page", "picture"]
|
||||
);
|
||||
|
||||
app.settings_state = Some(app.hydrate_settings_state());
|
||||
let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("guides".into()));
|
||||
let _ = app.handle_settings_msg(SettingsMsg::AddCategory);
|
||||
|
||||
assert!(
|
||||
app.post_editors[&edited.id]
|
||||
.available_categories
|
||||
.contains(&"guides".to_string())
|
||||
);
|
||||
|
||||
let _ = app.handle_post_editor_msg(PostEditorMsg::AddSuggestedCategory("notes".into()));
|
||||
|
||||
assert!(
|
||||
app.post_editors[&edited.id]
|
||||
.categories
|
||||
.contains(&"notes".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
meta::read_categories_json(tmp.path()).unwrap(),
|
||||
vec!["article", "aside", "guides", "notes", "page", "picture"]
|
||||
);
|
||||
assert!(
|
||||
app.post_editors[&edited.id]
|
||||
.available_categories
|
||||
.contains(&"notes".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_refresh_does_not_drop_loaded_semantic_tag_suggestions() {
|
||||
let (db, project, tmp) = setup();
|
||||
|
||||
@@ -11,6 +11,10 @@ impl BdsApp {
|
||||
post_id: String,
|
||||
tag: String,
|
||||
},
|
||||
EnsureCategory {
|
||||
post_id: String,
|
||||
category: String,
|
||||
},
|
||||
LoadSemanticTags(String),
|
||||
AddGalleryImages(String),
|
||||
DetectLanguage(String),
|
||||
@@ -194,9 +198,33 @@ impl BdsApp {
|
||||
}
|
||||
PostEditorMsg::CategoriesInputSubmit => {
|
||||
let cat = state.categories_input.trim().to_string();
|
||||
if !cat.is_empty() && !state.categories.contains(&cat) {
|
||||
state.categories.push(cat);
|
||||
if !cat.is_empty()
|
||||
&& !state
|
||||
.categories
|
||||
.iter()
|
||||
.any(|current| current.eq_ignore_ascii_case(&cat))
|
||||
{
|
||||
state.categories.push(cat.clone());
|
||||
state.mark_dirty();
|
||||
deferred = DeferredPostAction::EnsureCategory {
|
||||
post_id: state.post_id.clone(),
|
||||
category: cat,
|
||||
};
|
||||
}
|
||||
state.categories_input.clear();
|
||||
}
|
||||
PostEditorMsg::AddSuggestedCategory(category) => {
|
||||
if !state
|
||||
.categories
|
||||
.iter()
|
||||
.any(|current| current.eq_ignore_ascii_case(&category))
|
||||
{
|
||||
state.categories.push(category.clone());
|
||||
state.mark_dirty();
|
||||
deferred = DeferredPostAction::EnsureCategory {
|
||||
post_id: state.post_id.clone(),
|
||||
category,
|
||||
};
|
||||
}
|
||||
state.categories_input.clear();
|
||||
}
|
||||
@@ -321,6 +349,9 @@ impl BdsApp {
|
||||
DeferredPostAction::EnsureTag { post_id, tag } => {
|
||||
self.ensure_post_editor_tag(&post_id, &tag)
|
||||
}
|
||||
DeferredPostAction::EnsureCategory { post_id, category } => {
|
||||
self.ensure_post_editor_category(&post_id, &category)
|
||||
}
|
||||
DeferredPostAction::LoadSemanticTags(post_id) => {
|
||||
Task::done(Message::LoadSemanticTagSuggestions(post_id))
|
||||
}
|
||||
|
||||
@@ -10,16 +10,21 @@ use super::keyboard;
|
||||
/// Standard form field label color.
|
||||
pub const LABEL_COLOR: Color = rgb8(0xB5, 0xBA, 0xC4);
|
||||
pub const SECTION_COLOR: Color = rgb8(0x9D, 0xA5, 0xB4);
|
||||
const FIELD_BG: Color = rgb8(0x24, 0x24, 0x26);
|
||||
const SURFACE_BG: Color = rgb8(0x25, 0x25, 0x26);
|
||||
const BORDER_COLOR: Color = rgb8(0x3C, 0x3C, 0x3C);
|
||||
pub const APP_BG: Color = rgb8(0x0D, 0x11, 0x17);
|
||||
pub const ACTIVITY_BG: Color = rgb8(0x09, 0x0C, 0x10);
|
||||
pub const SIDEBAR_BG: Color = rgb8(0x11, 0x16, 0x1D);
|
||||
pub const SURFACE_BG: Color = rgb8(0x16, 0x1B, 0x22);
|
||||
pub const ELEVATED_BG: Color = rgb8(0x1C, 0x21, 0x28);
|
||||
pub const HOVER_BG: Color = rgb8(0x21, 0x26, 0x2D);
|
||||
pub const FIELD_BG: Color = APP_BG;
|
||||
pub const BORDER_COLOR: Color = rgb8(0x30, 0x36, 0x3D);
|
||||
const FOCUS_COLOR: Color = rgb8(0x00, 0x7F, 0xD4);
|
||||
const DANGER_BG: Color = rgb8(0xB6, 0x23, 0x24);
|
||||
const DANGER_HOVER: Color = rgb8(0xCF, 0x2F, 0x30);
|
||||
const PRIMARY_BG: Color = rgb8(0x0E, 0x63, 0x9C);
|
||||
const PRIMARY_HOVER: Color = rgb8(0x11, 0x77, 0xBB);
|
||||
const SECONDARY_BG: Color = rgb8(0x2D, 0x2D, 0x30);
|
||||
const SECONDARY_HOVER: Color = rgb8(0x3A, 0x3D, 0x41);
|
||||
const SECONDARY_BG: Color = HOVER_BG;
|
||||
const SECONDARY_HOVER: Color = BORDER_COLOR;
|
||||
|
||||
const fn rgb8(red: u8, green: u8, blue: u8) -> Color {
|
||||
Color {
|
||||
@@ -30,12 +35,12 @@ const fn rgb8(red: u8, green: u8, blue: u8) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Application-wide VS Code-like palette for native Iced controls.
|
||||
/// Application-wide deep dark palette for native Iced controls.
|
||||
pub fn app_theme() -> Theme {
|
||||
Theme::custom(
|
||||
"bDS".to_string(),
|
||||
iced::theme::Palette {
|
||||
background: Color::from_rgb8(0x1E, 0x1E, 0x1E),
|
||||
background: APP_BG,
|
||||
text: Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||
primary: PRIMARY_BG,
|
||||
success: Color::from_rgb8(0x2E, 0x7D, 0x32),
|
||||
@@ -48,9 +53,9 @@ pub fn app_theme() -> Theme {
|
||||
/// Opaque tooltip surface that remains legible over editor and sidebar content.
|
||||
pub fn tooltip_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))),
|
||||
background: Some(Background::Color(ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.35, 0.35, 0.40),
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -382,7 +387,7 @@ pub fn card<'a, Message: 'a>(content: impl Into<Element<'a, Message>>) -> Contai
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x31, 0x31, 0x33),
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 10.0.into(),
|
||||
},
|
||||
@@ -423,7 +428,7 @@ pub fn toolbar<'a, Message: 'a>(
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x31, 0x31, 0x33),
|
||||
color: BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 10.0.into(),
|
||||
},
|
||||
@@ -467,6 +472,19 @@ fn format_timestamp(ms: i64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn application_base_uses_the_deep_dark_palette() {
|
||||
assert_eq!(app_theme().palette().background, APP_BG);
|
||||
assert_eq!(APP_BG, rgb8(0x0D, 0x11, 0x17));
|
||||
assert_eq!(ACTIVITY_BG, rgb8(0x09, 0x0C, 0x10));
|
||||
assert_eq!(SIDEBAR_BG, rgb8(0x11, 0x16, 0x1D));
|
||||
assert_eq!(SURFACE_BG, rgb8(0x16, 0x1B, 0x22));
|
||||
assert_eq!(ELEVATED_BG, rgb8(0x1C, 0x21, 0x28));
|
||||
assert_eq!(HOVER_BG, rgb8(0x21, 0x26, 0x2D));
|
||||
assert_eq!(FIELD_BG, APP_BG);
|
||||
assert_eq!(BORDER_COLOR, rgb8(0x30, 0x36, 0x3D));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_controls_have_distinct_hover_and_focus_states() {
|
||||
let theme = app_theme();
|
||||
|
||||
@@ -549,9 +549,9 @@ fn shortcut_hint(action: MenuAction) -> Option<&'static str> {
|
||||
|
||||
fn menu_bar_style(_theme: &Theme) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x3C, 0x3C, 0x3C),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
@@ -561,9 +561,9 @@ fn menu_bar_style(_theme: &Theme) -> iced::widget::container::Style {
|
||||
|
||||
fn menu_popup_style(_theme: &Theme) -> iced::widget::container::Style {
|
||||
iced::widget::container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x4A, 0x4A, 0x4D),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -583,7 +583,7 @@ fn menu_button_style(
|
||||
) -> iced::widget::button::Style {
|
||||
let highlighted = active || matches!(status, iced::widget::button::Status::Hovered);
|
||||
iced::widget::button::Style {
|
||||
background: highlighted.then_some(Background::Color(Color::from_rgb8(0x3A, 0x3D, 0x41))),
|
||||
background: highlighted.then_some(Background::Color(inputs::HOVER_BG)),
|
||||
text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4),
|
||||
border: Border {
|
||||
radius: 4.0.into(),
|
||||
@@ -601,7 +601,7 @@ fn menu_item_style(
|
||||
let highlighted = selected || matches!(status, iced::widget::button::Status::Hovered);
|
||||
let disabled = matches!(status, iced::widget::button::Status::Disabled);
|
||||
iced::widget::button::Style {
|
||||
background: highlighted.then_some(Background::Color(Color::from_rgb8(0x3A, 0x3D, 0x41))),
|
||||
background: highlighted.then_some(Background::Color(inputs::HOVER_BG)),
|
||||
text_color: if disabled {
|
||||
Color::from_rgb8(0x7D, 0x7D, 0x7D)
|
||||
} else {
|
||||
@@ -655,7 +655,7 @@ fn window_menu_popup<'a>(
|
||||
.height(Length::Fixed(1.0))
|
||||
.width(Length::Fill)
|
||||
.style(|_| iced::widget::container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(0x3C, 0x3C, 0x3C))),
|
||||
background: Some(Background::Color(inputs::BORDER_COLOR)),
|
||||
..iced::widget::container::Style::default()
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::components::keyboard;
|
||||
use crate::components::{inputs, keyboard};
|
||||
use iced::widget::button;
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Column, Space, column, container, svg, text, tooltip};
|
||||
@@ -7,7 +7,6 @@ use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
use crate::state::navigation::SidebarView;
|
||||
|
||||
@@ -81,7 +80,7 @@ fn active_button_style(_theme: &Theme, _status: button::Status) -> button::Style
|
||||
/// Inactive button: hover brightens background.
|
||||
fn inactive_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -99,7 +98,7 @@ fn inactive_button_style(_theme: &Theme, status: button::Status) -> button::Styl
|
||||
/// Activity bar container: dark background.
|
||||
fn bar_background_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
background: Some(Background::Color(inputs::ACTIVITY_BG)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,10 +700,7 @@ fn draw_pie(frame: &mut canvas::Frame, series: &[ChartSeries], donut: bool) {
|
||||
start = end;
|
||||
}
|
||||
if donut {
|
||||
frame.fill(
|
||||
&Path::circle(center, radius * 0.54),
|
||||
Color::from_rgb8(0x25, 0x25, 0x26),
|
||||
);
|
||||
frame.fill(&Path::circle(center, radius * 0.54), inputs::SURFACE_BG);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Mess
|
||||
}
|
||||
|
||||
fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a, Message> {
|
||||
let card_bg = Color::from_rgb(0.15, 0.16, 0.20);
|
||||
container(
|
||||
column(
|
||||
std::iter::once(text(value).size(28).color(Color::WHITE).into())
|
||||
@@ -207,8 +206,8 @@ fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a
|
||||
)
|
||||
.padding(16)
|
||||
.width(Length::FillPortion(1))
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(card_bg)),
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: iced::Border {
|
||||
radius: 8.0.into(),
|
||||
..iced::Border::default()
|
||||
@@ -310,7 +309,7 @@ fn category_cloud<'a>(
|
||||
)
|
||||
.padding([6, 10])
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: iced::Border {
|
||||
radius: 999.0.into(),
|
||||
..iced::Border::default()
|
||||
@@ -374,7 +373,7 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme
|
||||
)
|
||||
.padding(12)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: iced::Border {
|
||||
radius: 8.0.into(),
|
||||
..iced::Border::default()
|
||||
|
||||
@@ -474,9 +474,9 @@ pub fn view<'a>(
|
||||
.padding(8),
|
||||
)
|
||||
.style(|_: &iced::Theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
|
||||
background: Some(iced::Background::Color(inputs::SURFACE_BG)),
|
||||
border: iced::Border {
|
||||
color: Color::from_rgb(0.28, 0.28, 0.32),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
|
||||
@@ -155,9 +155,9 @@ fn backdrop_style(_theme: &Theme) -> container::Style {
|
||||
|
||||
fn modal_box_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 10.0.into(),
|
||||
},
|
||||
@@ -693,11 +693,9 @@ pub fn view(
|
||||
.padding([3, 8])
|
||||
.style(|_: &Theme| {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(
|
||||
0.18, 0.20, 0.25,
|
||||
))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 999.0.into(),
|
||||
},
|
||||
@@ -713,10 +711,10 @@ pub fn view(
|
||||
)))
|
||||
.padding([8, 12])
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.22, 0.24, 0.30))),
|
||||
background: Some(Background::Color(inputs::HOVER_BG)),
|
||||
text_color: Color::from_rgb(0.85, 0.85, 0.90),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -769,10 +767,10 @@ pub fn view(
|
||||
.on_press(Message::PostEditor(PostEditorMsg::PostInsertLinkCreate))
|
||||
.padding([6, 12])
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
text_color: Color::from_rgb(0.60, 0.60, 0.65),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -886,11 +884,9 @@ pub fn view(
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(
|
||||
0.18, 0.20, 0.25
|
||||
))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -909,9 +905,9 @@ pub fn view(
|
||||
)))
|
||||
.padding(8)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
@@ -1029,11 +1025,9 @@ pub fn view(
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(
|
||||
0.18, 0.20, 0.25
|
||||
))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -1051,9 +1045,9 @@ pub fn view(
|
||||
))
|
||||
.padding(8)
|
||||
.style(|_: &Theme, _| button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.22, 0.27))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
@@ -1201,9 +1195,9 @@ pub fn view(
|
||||
)
|
||||
.padding(10)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
|
||||
@@ -121,9 +121,9 @@ fn task_status_color(status: &TaskStatus) -> Color {
|
||||
|
||||
fn task_entry_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26))),
|
||||
background: Some(Background::Color(inputs::SURFACE_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x3C, 0x3C, 0x3C),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
@@ -133,7 +133,7 @@ fn task_entry_style(_theme: &Theme) -> container::Style {
|
||||
|
||||
fn task_progress_style(_theme: &Theme) -> progress_bar::Style {
|
||||
progress_bar::Style {
|
||||
background: Background::Color(Color::from_rgb8(0x3C, 0x3C, 0x3C)),
|
||||
background: Background::Color(inputs::BORDER_COLOR),
|
||||
bar: Background::Color(Color::from_rgb8(0x0E, 0x63, 0x9C)),
|
||||
border: Border {
|
||||
radius: 3.0.into(),
|
||||
@@ -144,14 +144,14 @@ fn task_progress_style(_theme: &Theme) -> progress_bar::Style {
|
||||
|
||||
fn task_group_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let background = match status {
|
||||
button::Status::Hovered | button::Status::Pressed => Color::from_rgb8(0x2D, 0x2D, 0x30),
|
||||
_ => Color::from_rgb8(0x25, 0x25, 0x26),
|
||||
button::Status::Hovered | button::Status::Pressed => inputs::HOVER_BG,
|
||||
_ => inputs::SURFACE_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
text_color: Color::from_rgb8(0xCC, 0xCC, 0xCC),
|
||||
border: Border {
|
||||
color: Color::from_rgb8(0x3C, 0x3C, 0x3C),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
@@ -197,7 +197,7 @@ fn task_group_meta(members: &[&TaskSnapshot], locale: UiLocale) -> String {
|
||||
/// Panel background style.
|
||||
fn panel_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.13, 0.13, 0.16))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ fn panel_style(_theme: &Theme) -> container::Style {
|
||||
/// Panel tab button — active.
|
||||
fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||
button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.25))),
|
||||
background: Some(Background::Color(inputs::HOVER_BG)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.55, 0.90),
|
||||
@@ -219,7 +219,7 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||
/// Panel tab button — inactive.
|
||||
fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.18, 0.18, 0.22),
|
||||
button::Status::Hovered => inputs::ELEVATED_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -646,13 +646,13 @@ mod progress_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_cards_use_bds2_surface_and_status_palette() {
|
||||
fn task_cards_use_shared_surface_and_bds2_status_palette() {
|
||||
let style = task_entry_style(&Theme::Dark);
|
||||
assert_eq!(
|
||||
style.background,
|
||||
Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26)))
|
||||
Some(Background::Color(inputs::SURFACE_BG))
|
||||
);
|
||||
assert_eq!(style.border.color, Color::from_rgb8(0x3C, 0x3C, 0x3C));
|
||||
assert_eq!(style.border.color, inputs::BORDER_COLOR);
|
||||
assert_eq!(style.border.width, 1.0);
|
||||
let group_style = task_group_button_style(&Theme::Dark, button::Status::Active);
|
||||
assert_eq!(group_style.background, style.background);
|
||||
|
||||
@@ -88,6 +88,7 @@ pub struct PostEditorState {
|
||||
pub tags_input: String,
|
||||
pub categories_input: String,
|
||||
pub available_tags: Vec<String>,
|
||||
pub available_categories: Vec<String>,
|
||||
pub semantic_tag_suggestions: Vec<String>,
|
||||
pub ai_activity: Option<String>,
|
||||
pub active_language: String,
|
||||
@@ -139,6 +140,7 @@ impl Clone for PostEditorState {
|
||||
tags_input: self.tags_input.clone(),
|
||||
categories_input: self.categories_input.clone(),
|
||||
available_tags: self.available_tags.clone(),
|
||||
available_categories: self.available_categories.clone(),
|
||||
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
||||
ai_activity: self.ai_activity.clone(),
|
||||
active_language: self.active_language.clone(),
|
||||
@@ -208,6 +210,7 @@ impl PostEditorState {
|
||||
tags_input: String::new(),
|
||||
categories_input: String::new(),
|
||||
available_tags: Vec::new(),
|
||||
available_categories: Vec::new(),
|
||||
semantic_tag_suggestions: Vec::new(),
|
||||
ai_activity: None,
|
||||
active_language: canonical_lang.clone(),
|
||||
@@ -445,6 +448,7 @@ pub enum PostEditorMsg {
|
||||
RemoveTag(String),
|
||||
CategoriesInputChanged(String),
|
||||
CategoriesInputSubmit,
|
||||
AddSuggestedCategory(String),
|
||||
RemoveCategory(String),
|
||||
Save,
|
||||
Publish,
|
||||
@@ -770,9 +774,9 @@ pub fn view<'a>(
|
||||
chips.into()
|
||||
};
|
||||
let matching_suggestions =
|
||||
matching_tag_suggestions(&state.available_tags, &state.tags, &state.tags_input);
|
||||
matching_taxonomy_suggestions(&state.available_tags, &state.tags, &state.tags_input);
|
||||
let query_addable =
|
||||
tag_query_addable(&state.available_tags, &state.tags, &state.tags_input);
|
||||
taxonomy_query_addable(&state.available_tags, &state.tags, &state.tags_input);
|
||||
let matching_tags: Element<'a, Message> = if state.tags_input.trim().is_empty()
|
||||
|| (matching_suggestions.is_empty() && !query_addable)
|
||||
{
|
||||
@@ -819,6 +823,53 @@ pub fn view<'a>(
|
||||
Message::PostEditor(PostEditorMsg::CategoriesInputSubmit),
|
||||
|cat| Message::PostEditor(PostEditorMsg::RemoveCategory(cat)),
|
||||
);
|
||||
let matching_categories = matching_taxonomy_suggestions(
|
||||
&state.available_categories,
|
||||
&state.categories,
|
||||
&state.categories_input,
|
||||
);
|
||||
let category_query_addable = taxonomy_query_addable(
|
||||
&state.available_categories,
|
||||
&state.categories,
|
||||
&state.categories_input,
|
||||
);
|
||||
let category_suggestions: Element<'a, Message> = if state.categories_input.trim().is_empty()
|
||||
|| (matching_categories.is_empty() && !category_query_addable)
|
||||
{
|
||||
Space::new().into()
|
||||
} else {
|
||||
let mut chips = row![
|
||||
text(t(locale, "editor.matchingCategories"))
|
||||
.size(11)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center);
|
||||
for category in matching_categories {
|
||||
chips = chips.push(
|
||||
keyboard::button(text(category).size(11))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory(
|
||||
category.to_string(),
|
||||
)))
|
||||
.padding([4, 8])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
}
|
||||
if category_query_addable {
|
||||
let query = state.categories_input.trim().to_string();
|
||||
chips = chips.push(
|
||||
keyboard::button(
|
||||
text(tw(locale, "editor.createCategory", &[("name", &query)])).size(11),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedCategory(
|
||||
query,
|
||||
)))
|
||||
.padding([4, 8])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
}
|
||||
chips.wrap().into()
|
||||
};
|
||||
|
||||
// Post links sections
|
||||
let outlinks_section: Element<'a, Message> = if state.outlinks.is_empty() {
|
||||
@@ -968,6 +1019,7 @@ pub fn view<'a>(
|
||||
semantic_tags,
|
||||
matching_tags,
|
||||
categories_section,
|
||||
category_suggestions,
|
||||
outlinks_section,
|
||||
backlinks_section,
|
||||
linked_media_section,
|
||||
@@ -1152,7 +1204,7 @@ pub fn view<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn matching_tag_suggestions<'a>(
|
||||
fn matching_taxonomy_suggestions<'a>(
|
||||
available: &'a [String],
|
||||
selected: &[String],
|
||||
query: &str,
|
||||
@@ -1174,7 +1226,7 @@ fn matching_tag_suggestions<'a>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tag_query_addable(available: &[String], selected: &[String], query: &str) -> bool {
|
||||
fn taxonomy_query_addable(available: &[String], selected: &[String], query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
!query.is_empty()
|
||||
&& !available.iter().any(|tag| tag.eq_ignore_ascii_case(query))
|
||||
@@ -1437,7 +1489,7 @@ mod tests {
|
||||
let selected = vec!["Photography".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
matching_tag_suggestions(&available, &selected, "PHO"),
|
||||
matching_taxonomy_suggestions(&available, &selected, "PHO"),
|
||||
vec!["Photo Essay"]
|
||||
);
|
||||
}
|
||||
@@ -1448,9 +1500,27 @@ mod tests {
|
||||
.map(|index| format!("tag-{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(matching_tag_suggestions(&available, &[], "tag").len(), 8);
|
||||
assert!(!tag_query_addable(&available, &[], " TAG-3 "));
|
||||
assert!(tag_query_addable(&available, &[], "new tag"));
|
||||
assert_eq!(
|
||||
matching_taxonomy_suggestions(&available, &[], "tag").len(),
|
||||
8
|
||||
);
|
||||
assert!(!taxonomy_query_addable(&available, &[], " TAG-3 "));
|
||||
assert!(taxonomy_query_addable(&available, &[], "new tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_category_query_matches_existing_categories_case_insensitively() {
|
||||
let available = vec![
|
||||
"Article".to_string(),
|
||||
"Photo Essay".to_string(),
|
||||
"Photography".to_string(),
|
||||
];
|
||||
let selected = vec!["Photography".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
matching_taxonomy_suggestions(&available, &selected, "PHO"),
|
||||
vec!["Photo Essay"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,7 @@ use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::Project;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,9 +17,9 @@ use crate::i18n::t;
|
||||
|
||||
fn dropdown_bg(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 10.0.into(),
|
||||
},
|
||||
@@ -28,8 +29,8 @@ fn dropdown_bg(_theme: &Theme) -> container::Style {
|
||||
|
||||
fn project_item(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
|
||||
_ => Color::from_rgb(0.18, 0.18, 0.22),
|
||||
button::Status::Hovered => inputs::BORDER_COLOR,
|
||||
_ => inputs::ELEVATED_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
@@ -62,7 +63,7 @@ fn project_item_active(_theme: &Theme, status: button::Status) -> button::Style
|
||||
|
||||
fn new_project_btn(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
|
||||
button::Status::Hovered => inputs::BORDER_COLOR,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -163,7 +164,7 @@ pub fn view(
|
||||
items.push(
|
||||
container(Space::new().width(Length::Fill).height(Length::Fixed(1.0)))
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.30, 0.30, 0.35))),
|
||||
background: Some(Background::Color(inputs::BORDER_COLOR)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.width(Length::Fill)
|
||||
@@ -227,7 +228,7 @@ pub fn view(
|
||||
|
||||
pub fn trigger_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::views::git::GitUiState;
|
||||
/// Sidebar container style — dark background.
|
||||
fn sidebar_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
|
||||
background: Some(Background::Color(inputs::SIDEBAR_BG)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ fn sidebar_style(_theme: &Theme) -> container::Style {
|
||||
/// Sidebar item button style.
|
||||
fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -46,8 +46,8 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
/// Sidebar item button style — active/selected.
|
||||
fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32),
|
||||
_ => Color::from_rgb(0.22, 0.22, 0.28),
|
||||
button::Status::Hovered => inputs::BORDER_COLOR,
|
||||
_ => inputs::ELEVATED_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
@@ -63,7 +63,7 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
/// 40×40 thumbnail container: rounded corners, dark background.
|
||||
fn thumbnail_container_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
border: Border {
|
||||
radius: 6.0.into(),
|
||||
..Border::default()
|
||||
@@ -172,7 +172,7 @@ fn search_input_style(theme: &Theme, status: text_input::Status) -> text_input::
|
||||
/// Style for filter toggle / clear buttons in the header.
|
||||
fn filter_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
|
||||
button::Status::Hovered => inputs::BORDER_COLOR,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -206,8 +206,8 @@ fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button:
|
||||
/// Tag/category chip style (unselected).
|
||||
fn chip_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.24, 0.24, 0.30),
|
||||
_ => Color::from_rgb(0.18, 0.18, 0.22),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => inputs::ELEVATED_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
@@ -242,7 +242,7 @@ fn chip_selected_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
/// Calendar year/month button style.
|
||||
fn calendar_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -289,7 +289,7 @@ fn row_delete_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
_ => Color::from_rgb(0.60, 0.60, 0.65),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.25))),
|
||||
background: Some(Background::Color(inputs::HOVER_BG)),
|
||||
text_color,
|
||||
border: Border {
|
||||
radius: 4.0.into(),
|
||||
|
||||
@@ -143,7 +143,7 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
|
||||
.height(Length::Fill)
|
||||
.padding(24)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -8,6 +8,7 @@ use bds_core::engine::task::TaskStatus;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::state::navigation::TaskSnapshot;
|
||||
use crate::views::project_selector;
|
||||
@@ -19,7 +20,7 @@ use crate::views::project_selector;
|
||||
/// Status bar background.
|
||||
fn bar_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.12, 0.12, 0.16))),
|
||||
background: Some(Background::Color(inputs::ACTIVITY_BG)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +28,7 @@ fn bar_style(_theme: &Theme) -> container::Style {
|
||||
/// Airplane button — inactive (dim).
|
||||
fn airplane_inactive(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -63,7 +64,7 @@ fn airplane_active(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
/// Dropdown trigger button style.
|
||||
pub fn dropdown_trigger(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
button::Status::Hovered => inputs::HOVER_BG,
|
||||
_ => Color::TRANSPARENT,
|
||||
};
|
||||
button::Style {
|
||||
@@ -81,8 +82,8 @@ pub fn dropdown_trigger(_theme: &Theme, status: button::Status) -> button::Style
|
||||
/// Dropdown item style.
|
||||
pub fn dropdown_item(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.25, 0.25, 0.30),
|
||||
_ => Color::from_rgb(0.18, 0.18, 0.22),
|
||||
button::Status::Hovered => inputs::BORDER_COLOR,
|
||||
_ => inputs::ELEVATED_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
@@ -99,9 +100,9 @@ pub fn dropdown_item(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
/// Dropdown container background.
|
||||
pub fn dropdown_bg(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
|
||||
background: Some(Background::Color(inputs::ELEVATED_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
|
||||
@@ -178,16 +178,16 @@ fn theme_button_style(selected: bool, status: button::Status) -> button::Style {
|
||||
background: Some(Background::Color(if selected {
|
||||
Color::from_rgb8(0x1F, 0x45, 0x63)
|
||||
} else if hovered {
|
||||
Color::from_rgb8(0x32, 0x32, 0x35)
|
||||
inputs::HOVER_BG
|
||||
} else {
|
||||
Color::from_rgb8(0x24, 0x24, 0x26)
|
||||
inputs::FIELD_BG
|
||||
})),
|
||||
text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4),
|
||||
border: Border {
|
||||
color: if selected {
|
||||
Color::from_rgb8(0x00, 0x7F, 0xD4)
|
||||
} else {
|
||||
Color::from_rgb8(0x3C, 0x3C, 0x3C)
|
||||
inputs::BORDER_COLOR
|
||||
},
|
||||
width: if selected { 2.0 } else { 1.0 },
|
||||
radius: 6.0.into(),
|
||||
|
||||
@@ -26,9 +26,9 @@ const TAB_TITLE_MAX_LEN: usize = 17;
|
||||
/// Tab bar background.
|
||||
fn bar_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
|
||||
background: Some(Background::Color(inputs::SIDEBAR_BG)),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.25, 0.25, 0.30),
|
||||
color: inputs::BORDER_COLOR,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
@@ -39,7 +39,7 @@ fn bar_style(_theme: &Theme) -> container::Style {
|
||||
/// Active tab style.
|
||||
fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||
button::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.55, 0.90),
|
||||
@@ -53,8 +53,8 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
|
||||
/// Inactive tab style.
|
||||
fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.18, 0.18, 0.22),
|
||||
_ => Color::from_rgb(0.14, 0.14, 0.18),
|
||||
button::Status::Hovered => inputs::ELEVATED_BG,
|
||||
_ => inputs::SIDEBAR_BG,
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
|
||||
@@ -478,8 +478,8 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
|
||||
background: Some(Background::Color(match (selected, status) {
|
||||
(true, button::Status::Hovered) => Color::from_rgb(0.20, 0.24, 0.30),
|
||||
(true, _) => Color::from_rgb(0.17, 0.20, 0.25),
|
||||
(false, button::Status::Hovered) => Color::from_rgb(0.23, 0.24, 0.26),
|
||||
(false, _) => Color::from_rgb(0.18, 0.18, 0.19),
|
||||
(false, button::Status::Hovered) => inputs::HOVER_BG,
|
||||
(false, _) => inputs::ELEVATED_BG,
|
||||
})),
|
||||
border: iced::Border {
|
||||
radius: 12.0.into(),
|
||||
@@ -617,7 +617,7 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
|
||||
.padding([6, 8])
|
||||
.style(move |_theme: &Theme, _status| button::Style {
|
||||
background: Some(Background::Color(if selected {
|
||||
Color::from_rgb(0.22, 0.24, 0.30)
|
||||
inputs::HOVER_BG
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})),
|
||||
|
||||
@@ -5,6 +5,7 @@ use iced::{Background, Color, Element, Length, Theme};
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
|
||||
pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
||||
@@ -30,7 +31,7 @@ pub fn view(locale: UiLocale) -> Element<'static, Message> {
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::components::keyboard;
|
||||
use crate::components::{inputs, keyboard};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -45,7 +45,7 @@ use crate::views::{
|
||||
/// Main content area background.
|
||||
fn content_bg(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.11, 0.11, 0.14))),
|
||||
background: Some(Background::Color(inputs::APP_BG)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ fn content_bg(_theme: &Theme) -> container::Style {
|
||||
/// Sidebar resize drag handle style.
|
||||
fn drag_handle_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
||||
background: Some(Background::Color(inputs::BORDER_COLOR)),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ fn separator_h<'a>() -> Element<'a, Message> {
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(1.0))
|
||||
.style(|_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
|
||||
background: Some(Background::Color(inputs::BORDER_COLOR)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
|
||||
@@ -260,8 +260,7 @@ fn tagged_releases_are_built_and_packaged_with_rust_tools() {
|
||||
let workspace_manifest = fs::read_to_string(workspace.join("Cargo.toml")).unwrap();
|
||||
let core_manifest = fs::read_to_string(workspace.join("crates/bds-core/Cargo.toml")).unwrap();
|
||||
assert!(
|
||||
workspace_manifest
|
||||
.contains("pagefind = { version = \"1.5.2\", default-features = false }"),
|
||||
workspace_manifest.contains("pagefind = { version = \"1.5.2\", default-features = false }"),
|
||||
"Pagefind's unused Actix serving stack must stay disabled"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -9,6 +9,12 @@ Use the post editor as the visual reference for every screen. New UI must feel l
|
||||
- Follow `crates/bds-ui/src/views/post_editor.rs` for editor hierarchy and spacing.
|
||||
- Extend a shared primitive when several screens need the same treatment; do not copy color or border closures into each view.
|
||||
|
||||
### Base palette
|
||||
|
||||
- Use the named colors in `components::inputs`: `APP_BG` (`#0d1117`), `ACTIVITY_BG` (`#090c10`), `SIDEBAR_BG` (`#11161d`), `SURFACE_BG` (`#161b22`), `ELEVATED_BG` (`#1c2128`), `HOVER_BG` (`#21262d`), and `BORDER_COLOR` (`#30363d`).
|
||||
- Fields use `FIELD_BG`, which deliberately matches `APP_BG`; their border supplies separation.
|
||||
- Keep semantic status, selection, and accent colors separate from the base palette.
|
||||
|
||||
## Layout
|
||||
|
||||
- Use 16 px outer editor padding and 8–12 px spacing between groups.
|
||||
|
||||
@@ -31,6 +31,8 @@ common-refresh = Aktualisieren
|
||||
editor-semanticTagSuggestions = Vorschläge aus ähnlichen Beiträgen
|
||||
editor-matchingTags = Passende Tags
|
||||
editor-createTag = Tag erstellen: { $name }
|
||||
editor-matchingCategories = Passende Kategorien
|
||||
editor-createCategory = Kategorie erstellen: { $name }
|
||||
duplicates-title = Doppelte Beiträge finden
|
||||
duplicates-searching = Duplikate werden gesucht…
|
||||
duplicates-complete = Duplikatsuche abgeschlossen
|
||||
|
||||
@@ -31,6 +31,8 @@ common-refresh = Refresh
|
||||
editor-semanticTagSuggestions = Suggested from similar posts
|
||||
editor-matchingTags = Matching tags
|
||||
editor-createTag = Create tag: { $name }
|
||||
editor-matchingCategories = Matching categories
|
||||
editor-createCategory = Create category: { $name }
|
||||
duplicates-title = Find Duplicate Posts
|
||||
duplicates-searching = Searching for duplicates…
|
||||
duplicates-complete = Duplicate search complete
|
||||
|
||||
@@ -31,6 +31,8 @@ common-refresh = Actualizar
|
||||
editor-semanticTagSuggestions = Sugerencias de publicaciones similares
|
||||
editor-matchingTags = Etiquetas coincidentes
|
||||
editor-createTag = Crear etiqueta: { $name }
|
||||
editor-matchingCategories = Categorías coincidentes
|
||||
editor-createCategory = Crear categoría: { $name }
|
||||
duplicates-title = Buscar publicaciones duplicadas
|
||||
duplicates-searching = Buscando duplicados…
|
||||
duplicates-complete = Búsqueda de duplicados completada
|
||||
|
||||
@@ -31,6 +31,8 @@ common-refresh = Actualiser
|
||||
editor-semanticTagSuggestions = Suggestions d’articles similaires
|
||||
editor-matchingTags = Étiquettes correspondantes
|
||||
editor-createTag = Créer l’étiquette : { $name }
|
||||
editor-matchingCategories = Catégories correspondantes
|
||||
editor-createCategory = Créer la catégorie : { $name }
|
||||
duplicates-title = Rechercher les articles en double
|
||||
duplicates-searching = Recherche des doublons…
|
||||
duplicates-complete = Recherche des doublons terminée
|
||||
|
||||
@@ -31,6 +31,8 @@ common-refresh = Aggiorna
|
||||
editor-semanticTagSuggestions = Suggerimenti da post simili
|
||||
editor-matchingTags = Tag corrispondenti
|
||||
editor-createTag = Crea tag: { $name }
|
||||
editor-matchingCategories = Categorie corrispondenti
|
||||
editor-createCategory = Crea categoria: { $name }
|
||||
duplicates-title = Trova post duplicati
|
||||
duplicates-searching = Ricerca duplicati…
|
||||
duplicates-complete = Ricerca duplicati completata
|
||||
|
||||
@@ -37,7 +37,7 @@ value PostEditorMetadata {
|
||||
language: String? -- select from supported languages
|
||||
do_not_translate: Boolean -- checkbox
|
||||
slug: String -- read-only text input
|
||||
categories: List<String> -- chip input
|
||||
categories: List<String> -- autocomplete chip input
|
||||
template_slug: String? -- select (shown only when templates exist)
|
||||
post_links: PostLinksPanel
|
||||
linked_media: List<LinkedMediaItem>
|
||||
@@ -149,6 +149,13 @@ surface PostEditorSurface {
|
||||
-- it is an index read (no model inference) so it works offline and
|
||||
-- yields nothing when similarity is disabled or unindexed.
|
||||
|
||||
@guarantee CategoryAutocomplete
|
||||
-- Category input with autocomplete.
|
||||
-- While typing: substring match on existing category names (case-insensitive),
|
||||
-- top 8 shown, plus a "create category" row when the query is new.
|
||||
-- Selecting or creating a category adds it to the post and makes newly
|
||||
-- created categories available in project metadata for future suggestions.
|
||||
|
||||
@guarantee TranslationFlagsBar
|
||||
-- Row of flag emoji buttons inline with metadata toggle.
|
||||
-- One flag per language: canonical language + each translation.
|
||||
|
||||
@@ -296,8 +296,9 @@ rule ValidateSite {
|
||||
-- missing: expected routes with no non-empty index.html
|
||||
-- extra: index.html routes absent from the expected set, including
|
||||
-- zero-byte files
|
||||
-- stale: existing post routes whose source mtime is more than one second
|
||||
-- newer than both the output mtime and tracked generation time
|
||||
-- stale: every existing generated route for a post whose source mtime is
|
||||
-- more than one second newer than both the output mtime and
|
||||
-- tracked generation time, including canonical and flat page URLs
|
||||
-- Standalone HTML, XML, JSON, assets, Pagefind files, and content hashes are
|
||||
-- not validation comparison inputs.
|
||||
ensures: ValidationReport(missing_pages, extra_pages, stale_pages)
|
||||
|
||||
Reference in New Issue
Block a user