Commit Graph
732 Commits
Author SHA1 Message Date
kate522andClaude Opus 5 7c33482be7 feat(creator-studio): icon toolbar with block tools, drop author strip
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 22:30:08 +08:00
kate522andClaude Opus 5 7bdf7cf018 feat(creator-studio): centered paper layout and scheduled publishing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 21:33:40 +08:00
kate522andClaude Opus 5 384d2b7aa0 feat(creator-studio): switch writing editor to TinyMCE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 20:51:18 +08:00
kate522andClaude Opus 5 4868de2cda fix(creator-studio): 加载正文编辑格式工具栏
选中文字时的 RichText 加粗、斜体、链接等工具由 WordPress format-library 注册。
BlockTools 虽然提供 inline toolbar slot,但此前前台没有加载 wp-format-library,
因此正文选区没有格式工具。为 Creator Studio 前台显式加载该 script/style handle,
并将行内格式工具栏统一为深色圆角悬浮样式。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 17:59:17 +08:00
kate522andClaude Opus 5 f5e9420266 feat(creator-studio): 图片宽度四档与作者行
- 通过 editor.BlockEdit 过滤器给 core/image 增加「半屏 / 中等 / 通栏 / 全宽」
  四档宽度,选中图片时出现在区块工具条;宽度写入 className 并序列化到
  post_content,前台样式即可生效,无需自定义区块或额外元数据
- 前台样式:半屏靠左 50%,中等 72%,通栏跟随正文行宽,全宽突破行宽但受容器
  内边距约束;移动端半屏与全宽回退为整行,避免横向滚动
- 新增作者行(头像 + 昵称 + 身份说明),数据由 CreatorRest::author_payload()
  从服务端给出,前端不自行拼装;无昵称时整行不渲染,头像缺失时回退首字占位
- 作者行放在摘要之后、正文之前

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 16:05:19 +08:00
kate522andClaude Opus 5 2c7262770b fix(creator-studio): 修复白屏与前台 REST 鉴权
白屏根因:CreatorRest / SubmissionVersion 位于 Jio\Core 命名空间,
但调用 Jio\Content\PrivateAssets 时缺少 use 导入,PHP 就近解析为不存在的
Jio\Core\PrivateAssets,在 wp_head 中抛致命错误导致页面输出截断。

- 为 CreatorRest、SubmissionVersion 补上 use Jio\Content\PrivateAssets
- 新增前台 api-fetch nonce 中间件:WordPress 只在后台注入 wpApiSettings,
  前台工作台里 Gutenberg core-data 请求(/wp/v2/users/me)因此全部 403
- 提交前先落一次草稿,避免自动保存未触发时发布到旧内容
- 新增 tools/check-class-refs.php:静态检测命名空间内未导入的裸类引用
- 新增 assets/creator-studio/scripts/smoke.cjs:浏览器冒烟脚本,
  抓控制台错误、失败请求与未捕获的 Promise 拒绝

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 15:39:10 +08:00
kate522andClaude Opus 5 fac4a714e0 feat(creator-studio): 前台 Gutenberg 创作工作台与受保护付费附件
前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化,
React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、
审核与付费的唯一权威。

- 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包),
  仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单
- 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、
  移动端全屏单列与安全区适配
- 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期
- 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变;
  原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹
- 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0;
  文件存放 ABSPATH 之外,未配置私有存储时 fail closed
- 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的
  订单、VIP 与配额判定;中断传输归还次数
- 付费字段改为写入前校验,非法价格不再留下半保存文章
- 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-06 14:53:23 +08:00
kate522andClaude Opus 5 fbb53c6890 feat(categories): align archive and directory pages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 23:51:33 +08:00
kate522andClaude Opus 5 7faa2d53d8 fix(admin): improve native link manager filters
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 22:19:50 +08:00
kate522andClaude Opus 5 f53cdd4dc8 feat(links): align the friends page layout and submit UI
- assemble the friends template with page header, search cover, content position, widget zones and comments
- render the three page title styles and the tabbed search cover
- restore card, category nav and submit modal markup for the friends page
- add page content style support to the page editor
- port the linked card, category nav and submit modal styles to the mobile and desktop breakpoints

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 21:09:40 +08:00
kate522andClaude Opus 5 4c562428b4 feat(links): move friend links to native WordPress links with review flow
- enable the native Links manager and drop the custom friend-link option/REST path
- add per-page link settings for the friends template
- render link categories from get_bookmarks() with search and page-content options
- add a validated public link submission endpoint with logo upload, captcha, rate limiting and duplicate checks
- create pending links for review and notify administrators by email and in-app message
- add native Links admin pending count, columns and edit notes
- cover the new submission and cleanup contracts

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 19:54:33 +08:00
kate522andClaude Opus 5 6bcfbfb252 feat(branding): default both site logos to assets/img/logo.png
- Point 站点 Logo / 暗色模式 Logo schema defaults (PHP + VUE) and the
  Settings::logo_url() fallback at assets/img/logo.png.
- Point the footer night image default at the same file.
- Remove logo_dark.png and the now-unreferenced assets/icons/default-logo.svg
  along with the private default_asset_url() helper that was its only reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 18:02:51 +08:00
kate522andClaude Opus 5 8e74b60570 fix(navigation): let CSS size the nav logo and drop the inline width cap
The navbar logo carried an inline `height:36px;width:auto;max-width:{logo_width}px`
while `.navbar-logo > img` already owns `height:36px;padding:0 30px`. Under
`box-sizing:border-box` that max-width capped the border box, so the 60px of
horizontal padding stayed inside it and wide logos were squeezed against a
fixed height, breaking the intrinsic aspect ratio.

- Render the nav logo with no inline sizing; CSS alone sizes it.
- Add a `$lazy` argument to jio_get_adaptive_theme_img() so the navbar can emit
  the real src up front instead of a square lazyload placeholder; defaults to
  'auto' so every other caller keeps the lazy_other behaviour.
- Remove the superseded logo_width plumbing: the setting (PHP + VUE schemas),
  Settings::logo_width(), jio_get_site_logo_width(), the --jio-logo-width token
  and the unused has_logo context key.
- Cover the contract in the header reference test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:50:56 +08:00
kate522andClaude Opus 5 3ce5bfc20c feat(branding): default the site icons to assets/img/favicon.png
- Point the 浏览器图标 / 移动端图标 schema defaults (PHP + VUE) at
  assets/img/favicon.png instead of an empty media field.
- Fall back to the same PNG in Settings::favicon_url() when no custom
  favicon and no native site_icon are configured.
- Add Settings::mobile_icon_url() and emit apple-touch-icon from SEOHead;
  the 移动端图标 field previously had no consumer and never reached the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 17:00:50 +08:00
kate522andClaude Opus 5 b56b9bbe37 fix(navigation): restore the reference 8px radius and unclip the mega menu
Traced against the running site (docker theme at AKSRT-theme) with real
computed styles:

- theme_main_radius defaulted to 12, so --main-radius was 12px and every
  dropdown panel, card and button was rounder than the reference, which
  sets 8px. Default is now 8 in the runtime reader and both schemas.
- The public bundle loads after main.min.css. Its bare img rule replaced
  the inline image baseline the menu relies on, and html{overflow-x} made
  the root a clipping context that crops the fixed senior mega menu.
  Reset is now scoped to content; the horizontal-overflow guard is on body.
- The senior mega menu renders a .container inside the dropdown where the
  reference keeps Bootstrap's 15px gutter; the padding-free site-shell
  .container rule stripped it, running the card/link grid to the edges.

Rebuilt public.css / public.min.css and the admin SPA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 12:33:10 +08:00
kate522andClaude Opus 5 3595935475 refactor(moderation): remove the Akismet backend and its admin fields
The comment moderation pipeline no longer calls Akismet. Removed:
- check_akismet() and the Akismet step in Moderation::process().
- akismet_* config keys from Moderation defaults and ExtensionRuntime.
- akismet decision state, the jio_moderation_akismet / akismet_result
  comment meta writes, and the legacy-meta fallbacks in the comments column.
- The Akismet fieldset in both the PHP and Vue permissions schemas.

Rate limit, GeoIP, blocked keywords and AI moderation are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 11:27:24 +08:00
kate522andClaude Opus 5 e6d9ff49a2 fix(navigation): align mega-menu rendering and menu-item backend with the reference
Renderer:
- Graphic-card images carry fit-cover so they crop into the fixed card box
  instead of stretching it and pushing the title out of alignment.
- Mega-menu link icons use fa-fw (fixed glyph column) instead of the
  undefined menu-item-icon class, so link titles line up.

Backend:
- Restore show_policy (login / vip / vip_2 / auth) on menu items; the field
  was missing entirely, so items could not be gated by identity.
- Default submenu_type to graphic_card, badge_class to jb-red, arrangement
  to swiper, size to md.
- Clamp img_scale to 20-300 to match the reference's spinner bounds.
- Drop the now-meaningless "none" submenu_type option: the senior-submenu
  switch is itself the on/off control.
- Admin JS gains the align control (left/center/space-around).

Cleanup:
- Remove the unused header_actions nav menu location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-05 10:51:36 +08:00
kate522 dcd306b428 feat(withdrawal): make payout channels a configurable sortable list
The payout channel was a two-item checkbox (alipay / wechat). Sites that pay
out through a bank transfer, USDT or any other route had no way to express it,
and every surface behind it — the withdrawal modal, the server-side check, the
admin review list — hard-coded those two names.

affiliate_payout_channels is now a repeater on both the PHP and Vue schemas,
using the same draggable component as the post filters and gateway config. Each
row carries a stable key, a display label, an account placeholder, an optional
note and an enabled flag. Disabled channels disappear from the front end but
stay resolvable, so historical withdrawal records still render their original
name.

PaymentRuntime::payout_channels() normalises the stored structure: it drops rows
with an empty key or label, de-duplicates keys, and falls back to the built-in
two channels when nothing is configured. enabled_payout_channels() feeds the
front end and payout_channel() resolves a single key for record display.
CommissionLedger's validation now compares against the configured, enabled keys
instead of a literal allowlist.

The withdrawal modal renders the configured list and swaps the account
placeholder and note when the channel changes; credits.js drives that. The
admin review page reads the same settings for its method labels and filter, and
the review dialog gained a "渠道说明" row so the operator sees the payout
instruction, hidden when empty.

Default channels live in SchemaPayment as plain data — the runtime imports them
rather than the reverse, because the schema layer must not depend on a runtime
settings class (doing it the other way made the schema harness fatal).

Verification: drove payout_channels() through default / custom / dirty / duplicate
inputs and confirmed enabled filtering, note lookup and disabled-channel
resolution. Added payment-withdrawal-channels-contract.test.cjs covering the
schema shape, runtime helpers, front-end wiring and admin read path. 873 tests
pass, PSR-4 audit clean, admin SPA builds.
2026-11-04 19:16:39 +08:00
kate522 b1c3d86e44 refactor(oauth): drop the hand-written providers and keep only what the SDK ships
The previous commit added five direct clients (Douyin, Microsoft, Facebook,
Twitter, Apple) written against each platform's own endpoints. Maintaining
five bespoke OAuth implementations — including Apple's ES256 client secret and
Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for,
and none of them could be verified without production credentials.

Only providers yurunsoft/yurun-oauth-login actually implements stay on the
direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay.
Baidu and Alipay move into OAuthProviderBroker's existing client() switch and
normalize() map rather than living in a separate class, so there is no new
layer to reason about.

OAuthProviderClient, its harness and its contract test are deleted outright
(-809 lines). The Apple form_post handling added to handle_callback() is
reverted too — without Apple there is no POST callback to read.

The aggregate-login channel keeps its 16 platforms, matching the reference;
that list describes what the aggregation provider offers, not a local
implementation. Brand icons for those names stay for the same reason.

Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0
errors, and the catalog constant, PHP schema and Vue schema now list the same
16 aggregate platforms.
2026-11-04 18:33:37 +08:00
kate522 6db34646ed feat(oauth): add the seven missing providers and open the full aggregate list
The theme shipped six direct providers while the reference carries fourteen,
and its aggregate-login channel offered three of sixteen platforms.

OAuthProviderClient now owns the providers the bundled SDK does not cover.
Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2
endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both
(Alipay needs its application private key, so provider_config learned an
extra_keys map to surface it). The broker dispatches through handles(), so the
existing start/callback routes stay provider-agnostic.

Three provider quirks needed handling rather than a plain copy:

Apple returns the callback as response_mode=form_post, and handle_callback
only read $_GET, so it now reads $_POST when the request is a POST — otherwise
every Apple login would fail with code_missing. Its client secret is built as
an ES256 JWT from Team ID / Key ID / .p8 rather than a static string.

Twitter requires PKCE. The verifier is derived from the state token, so it
travels with the existing transaction row and needs no extra storage.

Facebook hands back protocol-less avatar URLs, which are upgraded to https the
same way the OAuth avatar resolver does.

Settings gained matching toggles and credential groups on both sides — the PHP
schema and the Vue schema — and the aggregate platform list went from three to
sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog
constant. Brand icons were extended so each new provider resolves to its
sprite; facebook and twitter moved to their colour sprites, and the
`twitter -> x` alias was dropped since it bypassed the new mapping.

The oauth-security contract previously asserted that Baidu and Alipay must not
appear on the OAuth surface. That was the old intent, so the assertion is
replaced with a positive one, keeping the separate check that the Alipay
payment gateway still exists.

Verification: a new harness stubs the HTTP layer and drives all five custom
clients — authorization URLs carry redirect/state (Twitter also a PKCE
challenge), identities normalize to the shared shape, Facebook's avatar is
upgraded to https, Apple's private-relay email survives, and an error payload
throws instead of yielding an empty identity. 871 widget/navigation tests pass
and the PSR-4 audit reports 0 errors.

Not covered: no live handshake happened — every provider needs real AppID and
secret pairs, and Apple/Douyin require developer-account review before the
full round trip can be exercised.
2026-11-04 18:00:29 +08:00
kate522 6f29e3d865 fix(oauth): actually use the third-party avatar and seed it only once
The OAuth flow wrote the provider avatar to `jio_oauth_avatar`, but nothing
ever read it — every user who signed up through GitHub/QQ saw a Gravatar hash
or the default avatar instead of their own picture. The reference seeds its
`custom_avatar` from the OAuth profile and its resolver reads it back, so both
halves were missing here.

jio_get_custom_avatar_url() now falls back to the stored OAuth avatar when no
uploaded one exists, which puts the chain at uploaded → third-party → external
service → default. The theme already swaps WordPress's get_avatar_data, so one
change covers every avatar surface.

Writes go through a single seed_avatar() guard: it no-ops when an uploaded
avatar or an existing OAuth avatar is present, matching the reference's
`if ($args['avatar'] && !$custom_avatar)`. Previously both the bind path and
the login refresh overwrote the meta unconditionally, which would have wiped a
user's own picture on their next social login.

Because the OAuth avatar now shows through once the upload is gone, both
"restore default" flows (the REST endpoint and the admin profile box) delete
it too; the admin checkbox also appears when only an OAuth avatar is set,
since $avatar_url is now non-empty in that case.

Baidu serves avatars from tb.himg.baidu.com, which 403s — rewritten to
himg.bdimg.com the way the reference does. On top of that the URL is upgraded
to https when the site runs over SSL, because the reference's scheme-relative
form still yields a plain http:// when copied verbatim, and browsers block it
as mixed content.

WeChatShare was reading `custom_avatar` — a reference-theme key this codebase
never writes — so its author branch always fell through to get_avatar_url();
it now uses the shared resolver like every other surface.

Verification: drove the resolver through five states with a stub (none /
OAuth only / both / upload only / Baidu legacy host) and confirmed the
expected URL each time. Added oauth-avatar-contract.test.cjs to pin the
resolution order, the seed guard, and the reset cleanup. 868 widget and
navigation tests pass; the PSR-4 path audit reports 0 errors.
2026-11-04 17:41:48 +08:00
kate522 41366e7fc4 feat(auth): rebuild the sign-in surfaces on the shared sign-card contract
The login/register page and the auth modal had drifted into a parallel
system: floating-input fields, a sign-page-card shell and a bespoke
jio-auth-modal wrapper that none of the theme's own sign CSS targeted.
The reference card rules (.sign, .sign-img, .sign-content, .line-form-input,
.scale-placeholder, .social_loginbar, .title-h-left, #u_sign .modal-dialog,
.qrcode-signin-container) have been in main.css all along, so the fix is to
render the reference DOM instead of re-styling a second one.

auth-content.php now emits one .sign.jio-panel.blur-bg.relative card with a
.tab-content of #tab-sign-in / #tab-sign-up / #tab-resetpassword /
#tab-qrcode-signin / #tab-bind panes. Each pane opens with a
.title-h-left.fa-2x heading plus a .muted-color.px12 link, fields are
.relative.line-form.mb10 > .line-form-input + .scale-placeholder +
.line-form-line, the verification trigger is the .yztx.abs-right captchsubmit
button, and submits are .but.radius.jb-blue|jb-green.padding-lg.btn-block.
Social entries come from the shared render_social_buttons() separator +
.social_loginbar, and the OAuth bind pane uses the oauth-data-box avatar card.

The modal shell is #u_sign.modal > .modal-dialog > .sign-content >
.sign-img.absolute.hide-sm + .sign, and the page shell is the reference
.container.sign-page > .row.sign-row.gutters-5 > .col-md-6(.col-md-offset-6/
.col-md-offset-3).sign-col with a .fixed.lazyload background and a blurred
fixed footer. auth.js drives .in instead of .is-open, toggles the new
password/signin-mode/invite-gate controls and no longer touches the retired
markup. The page also drops two footer-hook removals that targeted functions
which no longer exist, and opts out of the fixed-nav offset.

The sign page's custom theme setting now actually applies: Router answers
jio_forced_theme_mode, jioData.themeForced wins over the stored cookie in
theme.js, theme-toggle positions are suppressed, and the page marks html/body
dark or white accordingly.

sign.css shrinks from ~114 lines of bespoke component styling to a thin
positioning layer, and the retired classes are gone rather than kept.

Verification: php -l on every changed PHP file, node --check on both JS files,
node --test across widget-system, navigation and front-submit (873 passing),
and tools/audit-psr4-paths.php / audit-admin-contracts.php both at 0 errors.
assets/js/runtime/main.min.js was rebuilt because theme.js gained the forced
theme branch.
2026-11-04 16:04:24 +08:00
kate522 8ad5ef9aff fix(single): align article code blocks with the reference theme
The article body ran two highlighting pipelines at once: the runtime
EnlighterJS pass (same one the comment area uses) plus a bespoke
precode.js enhancer, and a hand-rolled .code-block skin. The two
disagreed, so rendered blocks carried a dark #282c34 card with 15px
padding, 14px text, a border and a second copy button stacked on top of
the EnlighterJS toolbar.

Drop the parallel path and let one pipeline own code blocks:
- delete precode.js, single-post/code-blocks.css and the unused
  assets/js/enlighter copy that only precode.js referenced
- drop the now-dead has_code requirement (PHP scan + feature enqueue)
- render pre exactly as the reference does: bootstrap's pre box
  (9.5px padding / 13px / radius 4) plus main.css's muted background,
  with the EnlighterJS theme supplying the skin

Also scope the inline-code skin to :not(pre code) so it stops painting
the block background pink, and remap the toolbar copy glyph: \f0ea is
the clipboard in FA4 but "paste" in FA5; the clipboard is \f328.

Verified against the reference at 1440px both themes: pre 13px/18.57,
9.5px padding, radius 4, muted background; EnlighterJS block heights
151.31 / 60.38 match exactly.
2026-11-03 23:31:28 +08:00
kate522andClaude Opus 5 6c3c0f7c6d refactor(admin): fold code highlight and system notice into 常用功能
The 代码高亮 and 系统公告 schemas existed but appeared in no menu, so their
routes were never generated (routes come from the sidebar constants). The
reference keeps both inside the 常用功能 panel rather than as their own
entries, so follow that structure instead of adding sidebar items.

- global/common now carries three groups after the lazy-load ones:
  代码高亮 (switch / line numbers / buttons / max height / light+dark theme)
  and 弹窗通知 (policy, content, 4 buttons, size, radius, icon, expiry)
- group keys are uniquified (code-highlight, code-theme, notice-*) so they do
  not collide with the existing performance group
- delete the orphan code-highlight.js / system-notice.js schemas and their
  section entries on both the Vue and PHP sides
- delete post-list/pay.js and its PHP counterpart: every post_pay_* and
  post_download_* key there had zero runtime consumers, and the reference has
  no such page (paid-content config lives under 支付&付费)

tools/health and tools/import-export turned out to already be reachable as
custom pages, and remain untouched.

Tests: admin contract audit 0 errors (warnings 56 → 51); SPA builds clean;
suite 967/968 with the pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 17:50:32 +08:00
kate522andClaude Opus 5 451b04160b fix(comments): restore syntax highlighting for comment code blocks
Comment [code] blocks rendered as bare <pre><code> with only Bootstrap's
grey box — no syntax colours, unlike the reference.

The runtime gates EnlighterJS on `_win.highlight_kg`, which was hardcoded to
false. With that false the selector degrades to `pre code.gl, pre code.special`,
and comment code carries neither class, so it never highlighted. The admin
already exposes 「全局功能 → 代码高亮」 (default on), and jioData.codeHighlight
reads it — only the runtime payload disagreed.

- _win.highlight_kg / highlight_hh / highlight_zt (plus white/dark theme
  names) now come from Settings::get_code_highlight_config(); the initial
  theme follows cookie → mode → time-auto via initial_theme_mode()
- define those values before the _win localize (it sits above jioData in the
  same method)
- inject the reference's two head rules the runtime path was missing:
  max-height from `highlight_maxheight`, and
  `.enlighter-toolbar{display:none}` when `highlight_btn` is off

Tests: comment-image-upload contract gains cases pinning the settings-backed
booleans (and their definition order) and the two inline CSS rules; suite
837/838 with the pre-existing frontend-lifecycle failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 17:31:42 +08:00
kate522andClaude Opus 5 3c07ef7a8d fix(widgets): restore the 20px bottom gap under the quote and search widgets
The reference wraps these two widgets in a middle .theme-box layer
(widget-container > .theme-box > title + card), which is what supplies the
20px bottom margin. Our outer .jio-widget-box-wrap is only a transparent
layout container, so dropping .theme-box left the quote card flush against
the following module.

- search/quote: emit the reference .theme-box middle layer; the card itself
  stays margin-free like the reference, so the gap is exactly 20px
- keep the generic .widget-content branch for user_card/author_card and the
  other reference widgets untouched (the string is contract-tested)
- contract test now pins the .theme-box layer and asserts the quote card
  carries no margin class of its own

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 16:09:46 +08:00
kate522andClaude Opus 5 f80ca1e27d fix(widgets): restore single-card reference UI for search and quote widgets
The 搜索框 and 一言 sidebar widgets fell into the generic renderer branch, so
they rendered a .jio-panel card wrapping a second bespoke card
(.jio-widget-search / .jio-widget-quote). In-article 一言 also used
divergent modifier classes and an extra hover rule.

- search: render through the shared jio_get_search_box() (legacy keyword,
  history and category pickers) inside one reference card, mirroring the
  reference widget's markup and title default 「搜索」
- quote: render the reference .yiyan-box card itself as the click root; the
  shell now emits only the reference widget-container/title wrapper
- article 一言: adopt the reference class lists for header, footer and the
  standalone after-article box; drop the invented yiyan--* modifiers and
  the duplicate hover/opacity rules
- client: fill .cn/.en in the reference order (first line .cn, second .en)
  and drop the stale window.JioYiyan bridge plus the dead qv-yiyan.php AJAX
  path in runtime main.js
- schema: replace the ad-hoc search_style radio with the reference switches
  (keywords, history, input category, more_cats) and default title
- assets: enqueue assets/js/yiyan.js through the quote widget's asset list

Tests: widget-system suite 820/821 (the one failure is the pre-existing
frontend-lifecycle theme-init stub); new search/quote card contracts pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-03 15:56:52 +08:00
kate522andClaude Fable 5 1549d5b25d fix(payment): honor schema defaults for balance recharge and fix withdrawal warning
Fixes two issues:

1. **Balance recharge disabled on fresh install**: The `pay_balance_s`
   setting has `default: true` in SchemaPayment but was treated as
   disabled when the key was absent from the database. This meant users
   had to toggle it off and back on to activate the feature.

   Fixed by using `!isset() || !empty()` pattern to honor the schema
   default when the key is missing.

2. **PHP Warning in withdrawal settings**: `get_withdrawal_settings()`
   passed `$s['affiliate_withdrawal_reset_time']` directly to
   `preg_match()` without extracting it first, causing "Undefined
   array key" warnings when the option was unset.

   Fixed by extracting to `$reset_time_raw` before the regex check.

Changed:
- `PaymentRuntime::get_payment_recharge_settings()`: enabled defaults to true
- `PaymentRuntime::get_payment_gateway_settings()`: balance_enabled defaults to true
- `PaymentRuntime::is_gateway_enabled('balance')`: defaults to true
- `PaymentRuntime::get_withdrawal_settings()`: no longer warns on missing key

Added test script `tools/test-payment-runtime-defaults.php` to verify
the fix works with empty settings (fresh install).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-02 22:33:18 +08:00
kate522andClaude Opus 5 e30de6562c fix(routing): bootstrap permalink structure so fresh installs resolve single posts
A fresh install leaves `permalink_structure` empty, so WordPress snapshots
$wp_rewrite with no post/page permalink rules and every single post resolves
to 404 until someone re-saves Settings -> Permalinks.

The existing mitigation fired too late to help: routing.php wrote the option
and flushed from `init`, but WP_Rewrite was already constructed with the empty
structure, and core defers pre-`wp_loaded` flushes to a `wp_loaded` callback
that regenerates from that stale snapshot. The rewrite_rules option ended up
non-empty but broken, so the 404 persisted on every request.

New \Jio\Core\RewriteBootstrap owns this: on `after_setup_theme` (still inside
wp-settings.php, before wp-blog-header.php parses the query) it calls
$wp_rewrite->set_permalink_structure() — the same API wp-admin/options-permalink.php
uses — which persists the option *and* refreshes the in-memory snapshot, so both
the current request and the rules written for the next one are correct. A
structure an administrator chose is never overridden.

Also consolidates the two duplicated flush sites that lived inline in
functions.php (`after_switch_theme` closure and the `jio_flushed_version`
block, whose `delete_option('rewrite_rules')` forced a needless regenerate),
and drops the now-redundant permalink write from routing.php. The version
stamp moves to `wp_loaded` priority 20 so it sees the complete rule set
instead of the pre-`add_rewrite_rule()` snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 19:50:24 +08:00
kate522andClaude Opus 5 39aed0d430 fix(admin): show the real default URL for level and VIP icons
用户等级配置的「等级图标 URL」和 VIP 的「会员图标」在后台显示为空白输入框,
但前台实际渲染的是主题自带图标——后台既看不到当前效果,也无法判断该字段
是否已被配置。根因是 schema default 写成空串,而运行时(Level::
get_level_icon_url() / PaymentRuntime::get_vip_icon_url())在值为空时回退
到 assets/img 下的内置图标。

把 schema default 改为直接写入主题自带图标 URL,与运行时回退链保持同一路径、
同一 is_file 守卫:后台展示的就是前台真正使用的图标,清空则回到文本/FA 回退。

- SchemaUsers::default_level_options():icon_img_N 写入 user-level-N.png
- SchemaPayment::vip_icon_default():vip_N_img_icon 写入 user/vip/vip-N.svg
- level.js / vip.js 同步默认值,help 与 placeholder 文案改为描述默认图标
- 两个测试 harness 补齐 get_theme_file_uri/path stub(schema 现在需要它们)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 14:06:52 +08:00
kate522andClaude Opus 5 427968a721 chore(branding): remove leftover upstream theme identifiers
The theme was renamed Zibll/子比 → AKSRT → Jio, but upstream branding
survived the sweeps in CSS classes, the SVG sprite prefix, a persisted
widget field ID, JS events, admin Vue class names, test infrastructure
and docblocks.

Renames:
- zib-widget / zib-widget-wrap / zib-slider → jio-widget-box /
  jio-widget-box-wrap / jio-slider-box (jio-widget is taken by the modern
  widget shell, jio-box-* by the card primitives)
- zib-partners-scroll + zps-* → jio-partners-scroll + jps-*
- zib-combo-info-card + zci-* → jio-combo-info-card + jci-*
- zibsvg- → jiosvg-, including the exported picker helpers and the
  fa-zibsvg- family-prefix guard
- zibpay_type → pay_type; the dual-read fallback in PostQuery collapses
  to a single key
- [zibajax="submit"] dropped (no PHP emitter); zib_ajax.success →
  jio_ajax.success
- aks-* admin Vue classes → jio-*
- zib_palette() → palette()

User-facing vendor traces are gone: the floating "more" button default no
longer ships a zibll.com link or 子比主题 marketing copy, the notice
widget ships no preset promo entries, and the four 官网教程 links are
removed from the post-list admin schema.

Test infrastructure renamed to neutral names (docker-compose.reference.yml,
Caddyfile, article-baseline.json, modern-widgets-contract.test.cjs) with
their DB/volume/env/slug identifiers following.

Kept intentionally, documented in tests/fixtures/README.md: the golden
fixture reference fingerprint (a SHA-256 record of an external directory,
read by validate-widget-golden / generate-widget-golden-fixture /
audit-widget-system), the doesNotMatch guards that prove the old tokens are
gone, and the reference theme's own meta key in the visual fixtures.

No backward-compat or data migration: saved widget configs holding old
icon keys or the old field ID silently lose them, per the project's
rewrite-over-compatibility rule.

Verified: 132/132 node contract tests, php tests/widget-system/run.php
(146 assertions), audit-admin-contracts, audit-psr4-paths and
validate-widget-golden all green; tracked bundles regenerated and in sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-02 00:06:54 +08:00
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00
kate522andClaude Opus 5 5b16bc6e15 fix(frontend): route the VIP points-exchange entry through the custom currency name
/user/vip/ 档位购买卡下方的入口横条仍硬编码「积分兑换会员」,而后台
「积分配置 → 自定义货币名称」改名后不跟随。上一轮改名只覆盖了兑换弹窗
标题与会员弹窗内入口,页面自身的入口横条是另一处服务端渲染,漏改了。

入口横条(inc/helpers/aksrt-vip.php)
- aksrt_get_vip_points_exchange_entry_html 改取已加载的
  catalog.currency_name,回退 Settings::get_currency_name() 再回退「积分」

同一页面上其余漏改的兑换文案一并收口,命名与入口/弹窗保持一致:
- VipServiceModal:积分不足提示 → {货币名}不足,暂时无法兑换
- VipServicePurchase.exchange_with_points:兑换未启用 / 兑换无效 /
  不足三处 WP_Error、流水说明、订单备注(%1$s兑换会员 %2$d %1$s。)
  currency_name 在此单独解析,get_catalog 内的局部变量不在该作用域
- LedgerPresentation:vip_points_exchange 流水原因标签

契约测试新增源断言,并用两个 PHP harness 做运行时断言:入口横条
(改名后 / 已是会员不输出 / 缺 key 回退)与弹窗(余额充足 / 不足两分支)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 16:19:49 +08:00
XingandClaude Opus 5 4e62b325d2 fix(badges): keep the medal catalog from being cast to a string on save
user_medal_args was declared as type=json without sanitize_type, so
sanitize_field_value() fell through to sanitize_text_field((string) $value)
and stored the literal string "Array". validate() has no json shape check,
so the save reported success.

Consequence: parse_medal_catalog() json_decoded "Array" into an empty
catalog, the dictionary came back empty, and user_context() dropped every
owned badge — the front end showed "获取徽章" and the grant modal listed
nothing, hiding both the newly added and the pre-existing badges.

Add the missing sanitize_type to match its sibling level/integral fields,
and guard sanitize_field_value() so structured container types can never
reach the scalar fallback again. Mirrors the declaration in the Vue schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 15:47:09 +08:00
kate522andClaude Opus 5 5c6b422866 fix(frontend): route every user-center points label through the custom currency name
后台「积分配置 → 自定义货币名称」改名后,用户中心仍有大量硬编码
「积分」:做任务赚积分 / 购买积分 / 签到领积分、积分记录 / 积分任务、
每日可免费获取 XX 积分、我的积分、积分+1、创作中心积分收益等。

get_currency_name() 早已存在且可用,问题只是这些文案没有走它。
本PR 全部替换,无双路、无迁移:

服务端渲染(用户中心 renderers)
- credits-balance.php:购买/做任务赚/签到领 + 整个购买弹窗
- profile-credits.php:积分记录 / 积分任务 页签
- credits-points-tasks.php:每日上限三态与空态
- sidebar-creator.php / profile-creator.php:积分收益
- transfer-modal.php:积分转账提示
- Signin.php:签到详情弹窗(我的积分 / 奖励 tooltip / 积分+N)
- VipServiceModal.php:我的积分 / 积分兑换会员
- download-permission-card.php:购买价格

客户端渲染(JS 无法调用 PHP,注入 currencyName 到 aksrtData)
- signin.js:签到状态行「已签到 N 天 +1」
- credits.js:转账标题/金额标签 + 购买提示 toast
- cms-download.js:积分不足视图、我的积分 入口、余额不足文案
- pay-vip.js:积分兑换会员入口(优先 catalog.currency_name)
- order-center.js:订单行单位回退

契约测试同步改为断言模板构造而非死字符串,并新增两例:
改名后跟随、未注入时回退「积分」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 15:09:49 +08:00
kate522andClaude Opus 5 1c95f94bca refactor(admin): drop the SPA credits asset record browser
「用户近期资产记录」卡片在 SPA 积分配置页里按用户 ID + 资产类型 +
日期查 user meta 历史,再伪装成统一账本行(entries / operation_key /
ref_key)。SPA 只承载配置,运营记录归原生 用户 → 积分/余额 屏幕
(CreditsAdmin / BalanceAdmin),这条平行路径删除,不留双读或迁移。

- CmsCredits.vue: 移除卡片、表格、分页及 ledger* 状态与
  formatAssetValue/loadLedger/searchLedger/resetLedger
- users.js: 删除仅服务该卡片的 getCreditLog()
- UserController: 删除 /assets/transactions 路由、rest_get_credits_log()
  及其唯一调用方 asset_value_to_minor()
- 契约测试改为断言该能力不得回归

points_record / balance_record 本身保留,前端账本与原生后台仍在消费。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 14:20:07 +08:00
kate522 324bcfdadc fix(admin): verify the whole SPA build graph, not just the entry
find_missing_admin_chunks() only parsed index.js, but a lazily
imported page chunk pulls in its own deps (shared-api, PageHeader,
sortable-options…). A deploy that drops one of those still leaves the
entry internally consistent, so the existing notice never fires and the
failure only shows up when an admin opens that menu — every schemaPage
keeps working, which makes the breakage look arbitrary.

The same scan never looked at CSS at all: 18 stylesheets are reachable
from the graph and none of them were checked, so a missing page
stylesheet ships an unstyled screen with no warning.

Walk the module graph transitively from index.js, queueing each
discovered dependency, and match .js and .css specifiers. Deleting
PageHeader now also reports every page that depends on it.

This is a diagnostic hardening only — it makes an incomplete deploy loud
instead of guessing at the cause of a broken page.
2026-11-01 13:41:58 +08:00
kate522 df12f51199 fix(admin): stop cropping the layout preview images in image_select
AksImageSelectField pinned every preview to a 60x60 box with
object-fit:cover, but these are wide layout strips: header layouts are
597x57 (10.47:1), the footer template is 800x190 (4.21:1) and the slide
align set is 200x101. A 10:1 strip inside a square crop keeps ~6% of its
width, so the layout it is supposed to illustrate is unrecognisable.

Constrain the width instead and let the height follow, which is what the
reference theme's CSF image_select does (max-width:100%; height:auto).
Each field now declares the width that suits its images, and an option
may still override it: header 200, footer 240, slide align 160, mobile
header 120.

Only presentation changed. No image files and no stored values moved,
so existing settings are unaffected.
2026-11-01 12:18:14 +08:00
kate522andClaude Fable 5 9805a371d8 feat(admin): rebuild the payment center overview with ECharts analytics
- Overview page: realtime today vs. same time yesterday, 6 KPI cards with
  period-over-period deltas, trend (paid / net / refund curves + order count
  line), category curves, payment method donut, top 10 paid content,
  conversion / close / refund rates, todos and shortcuts
- Date presets, custom range and category filter via
  GET aksrt/v1/admin/pay-overview (manage_options + wp_rest nonce)
- Metrics: paid amount by paid date including later-refunded orders, refunds
  by refund date, net = paid - refund; CNY only, points and recharge excluded,
  foreign-currency orders listed separately; integer cents internally
- Daily records cached per month in transients (today/yesterday live),
  invalidated on order status change and on purge
- ECharts 6.1.0 subset bundled to assets/js/runtime/libs/echarts-pay.min.js
  (npm run build:pay-charts), loaded only on the overview page
- Split payment-attempt methods out of OrderRepositoryCore into
  OrderRepositoryAttempts; remove dead get_stats(), cash_decimal(),
  Order::get_stats() and the old .aksrt-native-links styles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-11-01 00:22:44 +08:00
kate522andClaude Fable 5 d98f8d7975 feat(admin): redesign withdrawal review page and drop dead withdrawal code
- Split PayWithdrawals into shell + PayWithdrawalsList flat sibling trait
- Add process/policy callout, open/paid/fee stats, status views with counts
- Filter by source and payout method; reset keeps the status view
- Show net payout with gross/fee breakdown, copyable payee, note column
- Replace inline select form with per-row actions and a confirm dialog;
  reject requires a reason; double-submit guard; retry reuses the
  idempotency key; redirect back preserving filters and paging
- Withdrawal: add get_status_counts() and method filter; get_stats() now
  returns open_gross / paid_net / paid_fee
- Remove unused REST routes (/payment/withdrawals, /transition),
  admin_withdrawals(), update_status(), has_open_withdrawal(),
  count_open(), resolve_gross_amount()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 21:06:52 +08:00
kate522andClaude Fable 5 1d6bc8f7cc feat(admin): add bulk actions and pending-record deletion to creator income
- Always render the checkbox column and bulk bar; disable with a reason
  instead of hiding when settlement is off or the page has no pending rows
- Replace the batch-settle button with a bulk action select (settle / delete)
  plus apply, matching the charge cards and invite codes pages
- Add per-row delete for pending records and Revenue::delete_pending(),
  guarded by status = 'pending' so settled ledger-backed rows are never removed
- Report done / failed counts for bulk settle and delete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:51:55 +08:00
kate522andClaude Fable 5 b25ebc5fff feat(admin): redesign creator income page with status views and safer settlement
- Split PayIncome into shell + PayIncomeList flat sibling trait
- Add policy callout, per-currency stats, status views, filter card
- Show source post/order/buyer, base amount x ratio, expected settle time
- Confirm single/batch settlement, selection count, double-submit guard
- Redirect back preserving filters; report settled/failed counts
- Revenue: add get_status_counts(), share admin_list_where(), drop dead settle()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:37:38 +08:00
kate522andClaude Fable 5 1273a0138e fix(admin): match bulk action select and apply button sizes
- Add shared .aksrt-native-bulk row that locks select and button to the
  same height, font size and line height
- Apply it to the charge cards and invite codes bulk action rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:13:09 +08:00
kate522andClaude Fable 5 4f6e17ba5e feat(admin): create and batch-generate coupons in dialogs
- Move "新建优惠码" / "批量生成" into header-launched <dialog>s
- Extract forms into PayCouponsForms trait with shared rule fields,
  hints, validation and currency/percent unit switching
- Generated codes card gets a copy-all button; list codes copy on click
- Drop old inline forms, render_generated_dump and the unread
  aksrt_coupon_generated redirect arg

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 19:03:49 +08:00
kate522andClaude Fable 5 92a3c2b8c7 fix(admin): stop stretching checkboxes full-width in the pay products dialog
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 18:45:56 +08:00
kate522andClaude Fable 5 dc6dbc05d8 feat(admin): quick-edit paid post settings in a dialog on the pay products page
- Rebuild aksrt_pay_products as list + in-page quick settings dialog
  (pay type, limit, cash/points prices, sales float, coupon, order expiry, copy)
- Query by _aksrt_pay_type with real pagination; drop order-summary fallback
  and the duplicate "Meta 销量" column
- Centralize pay meta writes in DomainModel::persist_pay_meta(); stop writing
  unread _aksrt_pay_modo / _aksrt_pay_limit keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 17:28:45 +08:00
kate522andClaude Fable 5 b9000e1b93 feat(admin): dock ticket reply composer in conversation and move notes to a dialog
Put the reply composer at the bottom of the conversation card so the
timeline and composer form one viewport-height panel; the send button is
visible on load and Ctrl/Cmd+Enter submits. Internal notes now open in a
dialog instead of expanding inline, so the sidebar no longer grows.

Remove the shared ticket_composer helper and the inline note expansion CSS;
both dialogs share ticket_dialog_head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 15:10:13 +08:00
kate522andClaude Fable 5 54cc5301c3 feat(admin): open ticket creation in a dialog on the list page
Move "代客提交工单" back into a native <dialog> on the ticket list while
the ticket workspace stays a full page. Closing asks before discarding
input, ignores backdrop clicks, resets the form and returns focus; an
uncertain create result keeps the form locked behind "重新加载".

Remove the ?view=new create page route and its styles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:57:35 +08:00
kate522andClaude Fable 5 d86a85e6a6 feat(admin): replace ticket dialogs with in-page views
Ticket handling on users.php?page=aksrt_tickets no longer uses dialogs.
The list, the workspace (?id=N) and the assisted create form (?view=new)
are server-rendered page views that carry the list filters and page
for going back. Writes still go through the ticket REST routes; on
success the page reloads with a native notice, and unsent drafts in
other forms survive the reload via sessionStorage. Uncertain or
conflicting writes lock every form and only offer a reload.

Remove dead code: UsersMenusTicketsDialogs, the aksrt_ticket_view AJAX
read and its nonce, dialog CSS/JS (backdrop, scroll lock, focus return,
read races) and the dialog runtime test. Fix the create link carrying
an unclamped page number on out-of-range list pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:45:27 +08:00
kate522andClaude Fable 5 48e54dc115 fix(tests): restore green contract suite
Drop the legacy credits_added message label per the hard-cut rule, switch
three remaining Font Awesome 4 icons to FA5 solid, and align two widget
contracts with the badge rule grouping and renderer list from fc04bf65.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 14:18:01 +08:00