Skip to content

Moved settings text fields to Shade - #29559

Merged
9larsons merged 2 commits into
mainfrom
codex/admin-settings-shade-fields
Jul 23, 2026
Merged

Moved settings text fields to Shade#29559
9larsons merged 2 commits into
mainfrom
codex/admin-settings-shade-fields

Conversation

@9larsons

Copy link
Copy Markdown
Contributor

What changed

  • replaced the legacy TextField usages in settings with direct Shade Field, Input, Textarea, and InputGroup compositions
  • preserved labels, descriptions, errors, addons, disabled states, and accessible input relationships without adding a settings-only field wrapper
  • removed the legacy TextField implementation, story, and design-system exports
  • added a semantic Shade control-surface token so filled controls remain visible in dark mode, and retained the green settings focus treatment

Why

This continues the admin-x-design-system removal while keeping field behavior explicit at each call site. Using Shade primitives directly avoids replacing one legacy abstraction with another and makes the remaining design-system dependency easier to remove.

Validation

  • pnpm --filter @tryghost/admin-x-settings lint
  • pnpm --filter @tryghost/admin-x-settings exec tsc --noEmit
  • pnpm --filter @tryghost/admin-x-settings test:unit (22 files, 213 tests)
  • pnpm --filter @tryghost/admin-x-design-system lint
  • pnpm --filter @tryghost/admin-x-design-system build
  • pnpm --filter @tryghost/shade lint
  • pnpm --filter @tryghost/shade test (25 files, 233 tests)
  • pnpm --filter @tryghost/shade build
  • focused Admin acceptance coverage for the migrated settings surfaces
  • manually verified dark-mode title/description and tier controls, including filled surfaces, input groups, and keyboard focus; no browser console warnings or errors

no ref
Settings should use Shade primitives directly so the legacy design system can be removed without introducing another settings-only abstraction.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The PR removes the Admin X TextField component, exports, and stories, then migrates settings forms to Shade Field, Input, label, description, and error primitives. Shared input styling now uses the control-surface theme token, with updated focus behavior and control sizing. Site, membership, growth, integration, newsletter, and general settings forms preserve their existing state and validation wiring while adopting the new markup.

Possibly related PRs

  • TryGhost/Ghost#29513 — Migrates additional settings text fields and validation messaging to Shade primitives.

Suggested reviewers: peterzimon

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: migrating settings text fields to Shade primitives.
Description check ✅ Passed The description is directly related and accurately summarizes the migration and supporting cleanup.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/admin-settings-shade-fields

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 23, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit 02b8604

Command Status Duration Result
nx run @tryghost/admin:test:acceptance ✅ Succeeded 6m 29s View ↗
nx run-many -t test:unit -p @tryghost/admin-x-d... ✅ Succeeded 4m 47s View ↗
nx run ghost-admin:test ✅ Succeeded 2m 53s View ↗
nx run ghost-monorepo:lint:boundaries ✅ Succeeded 23s View ↗
nx run-many -t lint -p ghost-monorepo,@tryghost... ✅ Succeeded 2m 6s View ↗
nx run @tryghost/admin:build ✅ Succeeded 2m 3s View ↗
nx run @tryghost/activitypub:test:acceptance ✅ Succeeded 45s View ↗
nx run-many --target=build --projects=tag:publi... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-23 14:49:42 UTC

no ref
Migrated Shade controls should preserve the compact geometry of the legacy settings fields.
@9larsons
9larsons marked this pull request as ready for review July 23, 2026 14:40
@9larsons
9larsons enabled auto-merge (squash) July 23, 2026 14:40
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.79%. Comparing base (64365e8) to head (e62be66).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #29559      +/-   ##
==========================================
+ Coverage   74.77%   74.79%   +0.01%     
==========================================
  Files        1611     1611              
  Lines      141698   141717      +19     
  Branches    17315    17323       +8     
==========================================
+ Hits       105956   105994      +38     
+ Misses      34670    34651      -19     
  Partials     1072     1072              
Flag Coverage Δ
admin-tests 55.64% <ø> (+0.19%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@9larsons
9larsons merged commit d240cd7 into main Jul 23, 2026
46 of 47 checks passed
@9larsons
9larsons deleted the codex/admin-settings-shade-fields branch July 23, 2026 14:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx`:
- Around line 60-63: Extract the duplicated compact-input className into a
shared exported constant or utility, then replace the inline string in
add-integration-modal.tsx, custom-integration-modal.tsx,
first-promoter-modal.tsx, slack-modal.tsx, title-and-description.tsx,
profile-tab.tsx, and social-links-tab.tsx with that shared symbol. Preserve the
existing styling and ensure all seven wrappers import and reuse the same
definition.
- Around line 64-68: The migrated form fields lack accessible descriptions
linking inputs to their helper and validation text. Update the field
implementations around the relevant Input, FieldError, and FieldDescription
elements to assign local IDs and pass matching aria-describedby values,
including both hint and error IDs when applicable; centralize the pattern only
if it fits existing structure. Apply this to add-integration-modal.tsx (64-68),
custom-integration-modal.tsx (131-135), first-promoter-modal.tsx (93-104),
slack-modal.tsx (64-76), title-and-description.tsx (70-94),
change-password-form.tsx (170-184), profile-tab.tsx (18-39), and
social-links-tab.tsx (18-57).

In
`@apps/admin-x-settings/src/components/settings/general/publication-language.tsx`:
- Around line 82-94: Restore the existing default-language and documentation
guidance in the custom locale field’s FieldDescription alongside the “Enter a
custom locale code.” prompt. Update the FieldDescription in the custom-locale
Field within the publication language component, while preserving the current
validation-error rendering.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx`:
- Around line 267-275: Add an accessible name to the durationInMonths control in
the add-offer modal by associating its InputGroupInput with a visible
FieldLabel, following the Free months implementation, or by supplying an
appropriate aria-label. Preserve the existing validation, value, and
change-handling behavior.
- Around line 282-288: Update the trial-duration Input in the overrides.type ===
'trial' branch to always receive a string value, using the existing sibling
amount-field convention so an unset or zero trialAmount renders as an empty
string while populated values render normally. Keep handleTrialAmountInput and
the existing error handling unchanged.
- Around line 153-160: Update the offer-name Input in the add-offer modal to use
formState.name as its controlled value by binding value to that string field.
Keep the existing handleNameInput and length-tracking behavior unchanged, and do
not reference a nonexistent overrides.name object.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx`:
- Around line 326-335: Add an accessible name to the duration-in-months field in
the modal’s duration input section, matching the established labeling pattern
used by the sibling “Free months” field and the equivalent add-offer modal.
Connect a FieldLabel to the InputGroupInput via a stable id/htmlFor pair, or
provide an appropriate aria-label directly on the input.

In
`@apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx`:
- Around line 282-283: Update the Subject input in the welcome email modal to
provide an accessible name by associating it with the visible “Subject:” label
or adding aria-label="Subject". Preserve the existing input styling and
behavior.

In
`@apps/admin-x-settings/src/components/settings/membership/portal/transistor-settings.tsx`:
- Line 39: Update the Box wrapper around the membership settings form contents
to use a vertical flex layout with gap-8, restoring spacing between the
separator, heading, switch, and enabled fields while preserving its existing
styling classes.

In
`@apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx`:
- Around line 202-210: Update the Name and Description inputs in the tier detail
modal so only one receives autofocus: preserve autofocus on Name for non-free
tiers and use the existing isFreeTier condition to autofocus Description for
free tiers. Remove the unconditional autofocus from the Name input.
- Around line 299-308: Update the trial_days onChange handler in the tier-detail
modal to sanitize the entire input by removing every non-digit character, not
just the first match. Preserve the existing state update and allow only numeric
characters to remain before parsing.

In
`@apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx`:
- Around line 47-51: Wire supporting messages to their controls in
theme-setting.tsx lines 47-51 and navigation-item-editor.tsx lines 32-50: assign
stable IDs to each FieldDescription and FieldError, then pass the applicable IDs
from the associated inputs through aria-describedby and aria-errormessage
respectively, preserving existing conditional rendering and validation behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c1e29b0-c5e1-4412-9275-35fadff882c5

📥 Commits

Reviewing files that changed from the base of the PR and between 64365e8 and e62be66.

📒 Files selected for processing (46)
  • .agents/skills/shade-input-surface-recipe/SKILL.md
  • apps/admin-x-design-system/src/global/form/form.stories.tsx
  • apps/admin-x-design-system/src/global/form/text-field.stories.tsx
  • apps/admin-x-design-system/src/global/form/text-field.tsx
  • apps/admin-x-design-system/src/index.ts
  • apps/admin-x-settings/src/app.tsx
  • apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx
  • apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx
  • apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx
  • apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx
  • apps/admin-x-settings/src/components/settings/advanced/integrations/webhook-modal.tsx
  • apps/admin-x-settings/src/components/settings/email/newsletters/add-newsletter-modal.tsx
  • apps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsx
  • apps/admin-x-settings/src/components/settings/general/invite-user-modal.tsx
  • apps/admin-x-settings/src/components/settings/general/publication-language.tsx
  • apps/admin-x-settings/src/components/settings/general/seo-meta.tsx
  • apps/admin-x-settings/src/components/settings/general/social-accounts.tsx
  • apps/admin-x-settings/src/components/settings/general/title-and-description.tsx
  • apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx
  • apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx
  • apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx
  • apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx
  • apps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsx
  • apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx
  • apps/admin-x-settings/src/components/settings/growth/offers/offer-success.tsx
  • apps/admin-x-settings/src/components/settings/growth/recommendations/add-recommendation-modal.tsx
  • apps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsx
  • apps/admin-x-settings/src/components/settings/growth/tips-and-donations.tsx
  • apps/admin-x-settings/src/components/settings/membership/access.tsx
  • apps/admin-x-settings/src/components/settings/membership/custom-fields/custom-field-modal.tsx
  • apps/admin-x-settings/src/components/settings/membership/member-emails/test-email-dropdown.tsx
  • apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx
  • apps/admin-x-settings/src/components/settings/membership/portal/account-page.tsx
  • apps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsx
  • apps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsx
  • apps/admin-x-settings/src/components/settings/membership/portal/transistor-settings.tsx
  • apps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsx
  • apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx
  • apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx
  • apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx
  • apps/admin-x-settings/src/components/settings/site/theme/theme-editor-input-modal.tsx
  • apps/shade/src/components/ui/input-group.tsx
  • apps/shade/src/components/ui/input-surface.stories.tsx
  • apps/shade/src/components/ui/input-surface.ts
  • apps/shade/tailwind.theme.css
  • apps/shade/theme-variables.css
💤 Files with no reviewable changes (3)
  • apps/admin-x-design-system/src/index.ts
  • apps/admin-x-design-system/src/global/form/text-field.tsx
  • apps/admin-x-design-system/src/global/form/text-field.stories.tsx

Comment on lines +60 to 63
className='[&_:where(input)]:h-[var(--control-height)] [&_:where(input)]:border-transparent [&_:where(input)]:bg-muted'
marginBottom={false}
marginTop={false}
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Same compact-input override className is copy-pasted verbatim into 7 different files. The string [&_:where(input)]:h-[var(--control-height)] [&_:where(input)]:border-transparent [&_:where(input)]:bg-muted is duplicated identically across every migrated form/settings-group wrapper. A future tweak to compact input geometry (or to the --control-height variable name) will require updating all 7 call sites in lockstep, and it's easy to miss one (as already happened with change-password-form.tsx, which relies on an ancestor for this styling instead of setting it itself).

  • apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx#L60-L63: extract the className into a shared exported constant/util and use it here.
  • apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx#L130-L130: use the same shared constant instead of the inline string.
  • apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx#L84-L84: use the same shared constant instead of the inline string.
  • apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx#L63-L63: use the same shared constant instead of the inline string.
  • apps/admin-x-settings/src/components/settings/general/title-and-description.tsx#L69-L69: use the same shared constant instead of the inline string.
  • apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx#L17-L17: use the same shared constant instead of the inline string.
  • apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx#L17-L17: use the same shared constant instead of the inline string.
📍 Affects 7 files
  • apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx#L60-L63 (this comment)
  • apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx#L130-L130
  • apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx#L84-L84
  • apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx#L63-L63
  • apps/admin-x-settings/src/components/settings/general/title-and-description.tsx#L69-L69
  • apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx#L17-L17
  • apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx#L17-L17
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx`
around lines 60 - 63, Extract the duplicated compact-input className into a
shared exported constant or utility, then replace the inline string in
add-integration-modal.tsx, custom-integration-modal.tsx,
first-promoter-modal.tsx, slack-modal.tsx, title-and-description.tsx,
profile-tab.tsx, and social-links-tab.tsx with that shared symbol. Preserve the
existing styling and ensure all seven wrappers import and reuse the same
definition.

Comment on lines +64 to +68
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor='integration-name'>Name</FieldLabel>
<Input aria-invalid={Boolean(errors.name) || undefined} id='integration-name' maxLength={191} placeholder='Custom integration' value={name} autoFocus onChange={e => setName(e.target.value)} onInput={() => errors.name && setErrors({name: ''})} />
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the removed TextField wired aria-describedby, to confirm this is a regression.
git log --all --diff-filter=D --name-only --pretty=format: -- '*text-field*' | sort -u

Repository: TryGhost/Ghost

Length of output: 409


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== text-field source excerpts =="
wc -l apps/admin-x-design-system/src/global/form/text-field.tsx apps/admin-x-design-system/src/global/form/url-text-field.tsx
sed -n '1,240p' apps/admin-x-design-system/src/global/form/text-field.tsx
sed -n '1,240p' apps/admin-x-design-system/src/global/form/url-text-field.tsx

echo "== current Field/Input definitions/usages =="
rg -n "export .*Field|function Field|const Field|FieldError|FieldDescription|aria-invalid|aria-describedby|data-invalid" apps/admin-x-design-system/src apps/admin-x-settings/src/components/settings -g '*.tsx' -g '*.ts' | head -200

Repository: TryGhost/Ghost

Length of output: 375


🏁 Script executed:

#!/bin/bash
set -u

echo "== repository files matching text-field/field/input =="
git ls-files | grep -E '(^|/)(text-field|url-text-field|.*[Ff]ield|.*[Ii]nput).*\.(tsx|ts)$' | head -200

echo "== exact paths if tracked =="
git ls-files '*text-field*' '*url-text-field*' || true
git ls-files | grep -E 'admin-x-design-system/.*/form/(text-field|url-text-field)\.(tsx|ts)$' || true

echo "== search for aria-describedby aria-invalid FieldError FieldDescription in repo =="
rg -n "aria-describedby|aria-invalid|FieldError|FieldDescription|data-invalid|Text.*Field|TextField" . -g '*.tsx' -g '*.ts' | head -300

Repository: TryGhost/Ghost

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Shade Field source =="
wc -l apps/shade/src/components/ui/field.tsx
sed -n '1,90p' apps/shade/src/components/ui/field.tsx
sed一'130,230p' apps/shade/src/components/ui/field.tsx

echo "== old text-field paths from git ls-files =="
git ls-files | grep -E 'admin-x-design-system|text-field|url-text-field' | head -200 || true
rg -n "aria-describedby|aria-invalid|FieldError|FieldDescription|TextField|urlText" apps/admin-x-design-system/tests apps/admin-x-design-system/src -g '*.tsx' -g '*.ts' 2>/dev/null | head -200 || true

echo "== specific migrated files from comment =="
for f in \
  apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx \
  apps/admin-x-settings/src/components/settings/general/title-and-description.tsx \
  apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx \
  apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx \
  apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx
do
  echo "--- $f"
  rg -n "aria-describedby|aria-invalid|data-invalid|<FieldError|<FieldDescription|id=|name: 'integration-name'|integration-title|firstpromoter-account-id|slack-webhook-url|site-title|old-password|new-password|confirm-password|staff-website|staff-email|staff-name|staff-slug|staff-location" "$f" || true
done

Repository: TryGhost/Ghost

Length of output: 3289


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Shade Field rest =="
sed -n '90,241p' apps/shade/src/components/ui/field.tsx

echo "== old text-field paths and aria props in design system =="
git ls-files | grep -E 'admin-x-design-system.*(text-field|url-text-field|TextField|UrlTextField|form/)' | head -200 || true
rg -n "TextField|useTextField|aria-describedby|aria-invalid|FieldError|FieldDescription|FieldLabel" apps/admin-x-design-system/src apps/admin-x-design-system/tests -g '*.tsx' -g '*.ts' 2>/dev/null | head -200 || true

echo "== specific migrated files from comment (aria-describedby and ids) =="
for f in \
  apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx \
  apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx \
  apps/admin-x-settings/src/components/settings/general/title-and-description.tsx \
  apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx \
  apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx \
  apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx
do
  echo "--- $f"
  rg -n "aria-describedby|aria-invalid|data-invalid|<FieldError|<FieldDescription|id=|webhook-name|webhook-target-url|eventErrorId|integration-name|integration-title|firstpromoter-account-id|slack-webhook-url|site-title|old-password|new-password|confirm-password|staff-website|staff-email|staff-name|staff-slug|staff-location" "$f" || true
done

echo "== diff stat/name-only if available =="
git diff --stat -- apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx apps/admin-x-settings/src/components/settings/general/title-and-description.tsx apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx || true

Repository: TryGhost/Ghost

Length of output: 13619


Wire aria-describedby for migrated field helper text. These migrated fields set aria-invalid on Input, but the sibling FieldError and FieldDescription text lacks an id and the input has no aria-describedby, so screen-reader users focused on the fields won’t automatically hear the hint or validation message. Add local ids on FieldError/FieldDescription and pass matching aria-describedby from the Input; consider centralizing this in a small shared helper.

📍 Affects 8 files
  • apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx#L64-L68 (this comment)
  • apps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsx#L131-L135
  • apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx#L93-L104
  • apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx#L64-L76
  • apps/admin-x-settings/src/components/settings/general/title-and-description.tsx#L70-L94
  • apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx#L170-L184
  • apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx#L18-L39
  • apps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsx#L18-L57
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsx`
around lines 64 - 68, The migrated form fields lack accessible descriptions
linking inputs to their helper and validation text. Update the field
implementations around the relevant Input, FieldError, and FieldDescription
elements to assign local IDs and pass matching aria-describedby values,
including both hint and error IDs when applicable; centralize the pattern only
if it fits existing structure. Apply this to add-integration-modal.tsx (64-68),
custom-integration-modal.tsx (131-135), first-promoter-modal.tsx (93-104),
slack-modal.tsx (64-76), title-and-description.tsx (70-94),
change-password-form.tsx (170-184), profile-tab.tsx (18-39), and
social-links-tab.tsx (18-57).

Comment on lines +82 to +94
<Field data-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}>
<FieldLabel htmlFor='custom-locale'>Site language</FieldLabel>
<Input
aria-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}
className='border-transparent bg-muted'
data-testid='locale-select'
id='custom-locale'
placeholder='e.g. pt-BR, sr-Cyrl, en'
value={publicationLanguage}
onChange={handleCustomLanguageChange}
/>
{validationError || errors.publicationLanguage ? <FieldError>{validationError || errors.publicationLanguage}</FieldError> : <FieldDescription>Enter a custom locale code.</FieldDescription>}
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore the custom-locale help content.

Line 93 drops the existing default-language/documentation guidance when “Other” is selected. Keep that guidance in the FieldDescription alongside the custom-code prompt so this migration preserves the prior description.

Proposed fix
-                {validationError || errors.publicationLanguage ? <FieldError>{validationError || errors.publicationLanguage}</FieldError> : <FieldDescription>Enter a custom locale code.</FieldDescription>}
+                {validationError || errors.publicationLanguage ? <FieldError>{validationError || errors.publicationLanguage}</FieldError> : <FieldDescription>Enter a custom locale code. {hint}</FieldDescription>}

As per PR objectives, migrated field descriptions should be preserved.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Field data-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}>
<FieldLabel htmlFor='custom-locale'>Site language</FieldLabel>
<Input
aria-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}
className='border-transparent bg-muted'
data-testid='locale-select'
id='custom-locale'
placeholder='e.g. pt-BR, sr-Cyrl, en'
value={publicationLanguage}
onChange={handleCustomLanguageChange}
/>
{validationError || errors.publicationLanguage ? <FieldError>{validationError || errors.publicationLanguage}</FieldError> : <FieldDescription>Enter a custom locale code.</FieldDescription>}
</Field>
<Field data-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}>
<FieldLabel htmlFor='custom-locale'>Site language</FieldLabel>
<Input
aria-invalid={Boolean(errors.publicationLanguage || validationError) || undefined}
className='border-transparent bg-muted'
data-testid='locale-select'
id='custom-locale'
placeholder='e.g. pt-BR, sr-Cyrl, en'
value={publicationLanguage}
onChange={handleCustomLanguageChange}
/>
{validationError || errors.publicationLanguage ? <FieldError>{validationError || errors.publicationLanguage}</FieldError> : <FieldDescription>Enter a custom locale code. {hint}</FieldDescription>}
</Field>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/general/publication-language.tsx`
around lines 82 - 94, Restore the existing default-language and documentation
guidance in the custom locale field’s FieldDescription alongside the “Enter a
custom locale code.” prompt. Update the FieldDescription in the custom-locale
Field within the publication language component, while preserving the current
validation-error rendering.

Comment on lines +153 to +160
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor='offer-name'>Offer name</FieldLabel>
<Input aria-invalid={Boolean(errors.name) || undefined} id='offer-name' maxLength={40} placeholder='Black Friday' onChange={(e) => {
handleNameInput(e);
setNameLength(e.target.value.length);
}}
onKeyDown={() => clearError('name')}
/>
<TextField
error={Boolean(errors.displayTitle)}
hint={errors.displayTitle}
maxLength={MAX_DISPLAY_TEXT_LENGTH}
placeholder='Black Friday Special'
title='Display title'
value={overrides.displayTitle.value}
onChange={(e) => {
}} onKeyDown={() => clearError('name')} />
{errors.name ? <FieldError>{errors.name}</FieldError> : <FieldDescription><div className='flex justify-between'><span>Visible to members on Stripe Checkout page</span><strong><span className={nameLengthColor}>{formatNumber(nameLength)}</span> / {formatNumber(40)}</strong></div></FieldDescription>}
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- locate file ---\n'
fd -a 'add-offer-modal\.tsx$' . || true

printf '\n--- file outline ---\n'
ast-grep outline apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx --view expanded || true

printf '\n--- relevant source lines ---\n'
cat -n apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx | sed -n '1,240p'

printf '\n--- occurrences of overrides, displayTitle, code, name in component ---\n'
rg -n "overrides|displayTitle|code|name|handleNameInput|setNameLength|value={overrides|onChange=\\(\\(e\\)" apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx

printf '\n--- read-only JSX prop extraction ---\n'
python3 - <<'PY'
from pathlib import Path
import re
p = Path('apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx')
s = p.read_text()
for i, line in enumerate(s.splitlines(), 1):
    if '<Input' in line:
        print(f'Line {i}: {' '.join(line.split())}')
PY

Repository: TryGhost/Ghost

Length of output: 243


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- locate file ---"
fd -a 'add-offer-modal\.tsx$' . || true

echo
echo "--- file outline ---"
ast-grep outline apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx --view expanded || true

echo
echo "--- relevant source lines ---"
cat -n apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx | sed -n '1,260p'

echo
echo "--- occurrences of overrides, displayTitle, code, name in component ---"
rg -n "overrides|displayTitle|code|name|handleNameInput|setNameLength|value={overrides|onChange=\\(\\(e\\)" apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx || true

echo
echo "--- read-only JSX Input prop extraction ---"
python3 - <<'PY'
from pathlib import Path
p = Path('apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx')
s = p.read_text()
for i, line in enumerate(s.splitlines(), 1):
    if '<Input' in line:
        print(f'Line {i}: {' '.join(line.split())}')
PY

Repository: TryGhost/Ghost

Length of output: 16754


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- occurrences of overrides, displayTitle, code, name in component ---"
rg -n 'overrides|displayTitle|code|name|handleNameInput|setNameLength|value=\{overrides|onChange=\(\(e\)' apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx || true

echo
echo "--- later source lines ---"
cat -n apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx | sed -n '260,580p'

echo
echo "--- read-only JSX Input prop extraction ---"
python3 - <<'PY'
from pathlib import Path
p = Path('apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx')
s = p.read_text()
for i, line in enumerate(s.splitlines(), 1):
    if '<Input' in line:
        print('Line %d: %s' % (i, ' '.join(line.split())))
PY

Repository: TryGhost/Ghost

Length of output: 23579


Make the Offer name input controlled.

formState.name is the source of truth here, so this Input should bind value={formState.name} to keep it in sync with the rest of the form. overrides.name does not exist; that field is a string on SidebarProps/formStateTypes, unlike code and displayTitle which use {value} objects.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx`
around lines 153 - 160, Update the offer-name Input in the add-offer modal to
use formState.name as its controlled value by binding value to that string
field. Keep the existing handleNameInput and length-tracking behavior unchanged,
and do not reference a nonexistent overrides.name object.

Comment on lines +267 to +275
<Field data-invalid={Boolean(errors.durationInMonths) || undefined}>
<InputGroup className='h-[var(--control-height)] border-transparent bg-muted' data-invalid={Boolean(errors.durationInMonths) || undefined}>
<InputGroupInput aria-invalid={Boolean(errors.durationInMonths) || undefined} data-testid='duration-months-input' type='number' value={overrides.durationInMonths === 0 ? '' : String(overrides.durationInMonths)} onChange={(e) => {
handleDurationInMonthsInput(e);
}}
onKeyDown={() => clearError('durationInMonths')}
/>
}} onKeyDown={() => clearError('durationInMonths')} />
<InputGroupAddon align='inline-end'><InputGroupText>{overrides.durationInMonths === 1 ? 'month' : 'months'}</InputGroupText></InputGroupAddon>
</InputGroup>
{errors.durationInMonths && <FieldError>{errors.durationInMonths}</FieldError>}
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"Duration in months" input has no accessible name.

Unlike "Free months" (edit-retention-offer-modal.tsx) which has a FieldLabel, this InputGroupInput has neither a FieldLabel/htmlFor pairing nor an aria-label. Screen reader users get no name for this control beyond the trailing "month(s)" addon text.

♿️ Proposed fix
 <InputGroupInput aria-invalid={Boolean(errors.durationInMonths) || undefined} data-testid='duration-months-input' type='number' value={overrides.durationInMonths === 0 ? '' : String(overrides.durationInMonths)} onChange={(e) => {
+    aria-label='Duration in months'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsx`
around lines 267 - 275, Add an accessible name to the durationInMonths control
in the add-offer modal by associating its InputGroupInput with a visible
FieldLabel, following the Free months implementation, or by supplying an
appropriate aria-label. Preserve the existing validation, value, and
change-handling behavior.

Comment on lines +282 to +283
<Input
className='w-full border-transparent bg-muted'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Associate the Subject input with its visible label.

Subject: is a div, so the migrated input has no accessible name. Add an associated <label> or aria-label="Subject".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsx`
around lines 282 - 283, Update the Subject input in the welcome email modal to
provide an accessible name by associating it with the visible “Subject:” label
or adding aria-label="Subject". Preserve the existing input styling and
behavior.


return (
<>
<Box className='[&_:where(input)]:h-[var(--control-height)] [&_:where(input)]:border-transparent [&_:where(input)]:bg-muted'>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore the form spacing inside this wrapper.

AccountPage's parent Form supplies the previous gap-8 only to direct children. Wrapping the former fragment in a plain Box collapses the spacing between the separator, heading, switch, and enabled fields. Add equivalent internal layout classes, e.g. flex flex-col gap-8.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/membership/portal/transistor-settings.tsx`
at line 39, Update the Box wrapper around the membership settings form contents
to use a vertical flex layout with gap-8, restoring spacing between the
separator, heading, switch, and enabled fields while preserving its existing
styling classes.

Comment on lines +202 to +210
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor='tier-name'>Name</FieldLabel>
<Input aria-invalid={Boolean(errors.name) || undefined} autoComplete='off' id='tier-name' maxLength={191} placeholder={isFreeTier ? 'Free' : 'Bronze'} value={formState.name || ''} autoFocus onChange={e => updateForm(state => ({...state, name: e.target.value}))} onKeyDown={() => clearError('name')} />
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor='tier-description'>Description</FieldLabel>
<Input autoComplete='off' autoFocus={isFreeTier} id='tier-description' maxLength={191} placeholder={isFreeTier ? 'Free preview' : 'Full access to premium content'} value={formState.description || ''} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Conflicting autoFocus on Name and Description for free tiers.

"Name" (L204) always sets autoFocus, while "Description" (L209) sets autoFocus={isFreeTier}. When isFreeTier is true, both inputs request focus simultaneously, which is undefined/conflicting behavior (last-mounted typically wins).

🐛 Proposed fix
-<Input aria-invalid={Boolean(errors.name) || undefined} autoComplete='off' id='tier-name' maxLength={191} placeholder={isFreeTier ? 'Free' : 'Bronze'} value={formState.name || ''} autoFocus onChange={e => updateForm(state => ({...state, name: e.target.value}))} onKeyDown={() => clearError('name')} />
+<Input aria-invalid={Boolean(errors.name) || undefined} autoComplete='off' id='tier-name' maxLength={191} placeholder={isFreeTier ? 'Free' : 'Bronze'} value={formState.name || ''} autoFocus={!isFreeTier} onChange={e => updateForm(state => ({...state, name: e.target.value}))} onKeyDown={() => clearError('name')} />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor='tier-name'>Name</FieldLabel>
<Input aria-invalid={Boolean(errors.name) || undefined} autoComplete='off' id='tier-name' maxLength={191} placeholder={isFreeTier ? 'Free' : 'Bronze'} value={formState.name || ''} autoFocus onChange={e => updateForm(state => ({...state, name: e.target.value}))} onKeyDown={() => clearError('name')} />
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor='tier-description'>Description</FieldLabel>
<Input autoComplete='off' autoFocus={isFreeTier} id='tier-description' maxLength={191} placeholder={isFreeTier ? 'Free preview' : 'Full access to premium content'} value={formState.description || ''} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
</Field>
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor='tier-name'>Name</FieldLabel>
<Input aria-invalid={Boolean(errors.name) || undefined} autoComplete='off' id='tier-name' maxLength={191} placeholder={isFreeTier ? 'Free' : 'Bronze'} value={formState.name || ''} autoFocus={!isFreeTier} onChange={e => updateForm(state => ({...state, name: e.target.value}))} onKeyDown={() => clearError('name')} />
{errors.name && <FieldError>{errors.name}</FieldError>}
</Field>
<Field>
<FieldLabel htmlFor='tier-description'>Description</FieldLabel>
<Input autoComplete='off' autoFocus={isFreeTier} id='tier-description' maxLength={191} placeholder={isFreeTier ? 'Free preview' : 'Full access to premium content'} value={formState.description || ''} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
</Field>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx`
around lines 202 - 210, Update the Name and Description inputs in the tier
detail modal so only one receives autofocus: preserve autofocus on Name for
non-free tiers and use the existing isFreeTier condition to autofocus
Description for free tiers. Remove the unconditional autofocus from the Name
input.

Comment on lines +299 to +308
<Field data-disabled={!hasFreeTrial || undefined}>
<FieldLabel className='sr-only' htmlFor='tier-trial-days'>Trial days</FieldLabel>
<InputGroup className='h-[var(--control-height)] border-transparent bg-muted'>
<InputGroupInput disabled={!hasFreeTrial} id='tier-trial-days' placeholder='0' value={formState.trial_days} onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))} />
<InputGroupAddon align='inline-end'><InputGroupText>days</InputGroupText></InputGroupAddon>
</InputGroup>
<FieldDescription><span className='mt-1'>
Members will be subscribed at full price once the trial ends. <a className='text-green' href="https://ghost.org/help/free-trials/" rel="noreferrer" target="_blank">Learn more</a>
</span>}
placeholder='0'
rightPlaceholder='days'
title='Trial days'
value={formState.trial_days}
hideTitle
onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))}
/>
</span></FieldDescription>
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate file and relevant lines =="
if [ -f apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx ]; then
  wc -l apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx
  sed -n '260,325p' apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx | cat -n -v
else
  echo "File not found"
fi

echo
echo "== Search for similar sanitizer patterns =="
rg -n "replace\\(/\^\\[\\\\d\\]\\/g|replace\\(/[\\^\\^]\\\\d\\]/|trial_days|Replace" apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx apps/admin-x-settings/src || true

echo
echo "== Behavioral sanitizer probe in Node (no repo code executed) =="
node - <<'JS'
const inputs = ["12a3b", "abc", "1a2b3c", ""];
console.log("No global flag:");
for (const s of inputs) console.log(JSON.stringify({in: s, no_global: s.replace(/[^\d]/, "")}));
console.log("With global flag:");
for (const s of inputs) console.log(JSON.stringify({in: s, global: s.replace(/[^\d]/g, "")}));
JS

Repository: TryGhost/Ghost

Length of output: 10382


Use a global sanitizer for trial_days.

replace(/[^\d]/, '') removes only the first non-digit character, so pasting values like 12a3b or 1a2b3c leaves trailing junk in the trial-days state before it reaches parseInt.

🐛 Proposed fix
-<InputGroupInput disabled={!hasFreeTrial} id='tier-trial-days' placeholder='0' value={formState.trial_days} onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))} />
+<InputGroupInput disabled={!hasFreeTrial} id='tier-trial-days' placeholder='0' value={formState.trial_days} onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/g, '')}))} />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Field data-disabled={!hasFreeTrial || undefined}>
<FieldLabel className='sr-only' htmlFor='tier-trial-days'>Trial days</FieldLabel>
<InputGroup className='h-[var(--control-height)] border-transparent bg-muted'>
<InputGroupInput disabled={!hasFreeTrial} id='tier-trial-days' placeholder='0' value={formState.trial_days} onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))} />
<InputGroupAddon align='inline-end'><InputGroupText>days</InputGroupText></InputGroupAddon>
</InputGroup>
<FieldDescription><span className='mt-1'>
Members will be subscribed at full price once the trial ends. <a className='text-green' href="https://ghost.org/help/free-trials/" rel="noreferrer" target="_blank">Learn more</a>
</span>}
placeholder='0'
rightPlaceholder='days'
title='Trial days'
value={formState.trial_days}
hideTitle
onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))}
/>
</span></FieldDescription>
</Field>
<Field data-disabled={!hasFreeTrial || undefined}>
<FieldLabel className='sr-only' htmlFor='tier-trial-days'>Trial days</FieldLabel>
<InputGroup className='h-[var(--control-height)] border-transparent bg-muted'>
<InputGroupInput disabled={!hasFreeTrial} id='tier-trial-days' placeholder='0' value={formState.trial_days} onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/g, '')}))} />
<InputGroupAddon align='inline-end'><InputGroupText>days</InputGroupText></InputGroupAddon>
</InputGroup>
<FieldDescription><span className='mt-1'>
Members will be subscribed at full price once the trial ends. <a className='text-green' href="https://ghost.org/help/free-trials/" rel="noreferrer" target="_blank">Learn more</a>
</span></FieldDescription>
</Field>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsx`
around lines 299 - 308, Update the trial_days onChange handler in the
tier-detail modal to sanitize the entire input by removing every non-digit
character, not just the first match. Preserve the existing state update and
allow only numeric characters to remain before parsing.

Comment on lines +47 to +51
<Field>
<FieldLabel htmlFor={`theme-setting-${setting.key}`}>{humanizeSettingKey(setting.key)}</FieldLabel>
<Input className='h-[var(--control-height)] border-transparent bg-muted' id={`theme-setting-${setting.key}`} value={fieldValues[setting.key] || ''} onBlur={() => handleBlur(setting.key)} onChange={event => handleChange(setting.key, event.target.value)} />
{setting.description && <FieldDescription>{setting.description}</FieldDescription>}
</Field>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg 'apps/admin-x-settings/src/components/settings/(site/design-and-branding/theme-setting.tsx|site/navigation/navigation-item-editor.tsx)$' || true

echo
echo "== theme-setting excerpt =="
if [ -f apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx ]; then
  nl -ba apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx | sed -n '1,120p'
fi

echo
echo "== navigation-item-editor excerpt =="
if [ -f apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx ]; then
  nl -ba apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx | sed -n '1,120p'
fi

echo
echo "== Field component definitions in Shade/admin packages =="
rg -n "export .*FunctionComponent.*Field|function Field|const Field|FieldDescription|FieldError|FieldLabel" -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: TryGhost/Ghost

Length of output: 422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== theme-setting excerpt =="
if [ -f apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx | sed -n '1,120p'
fi

echo
echo "== navigation-item-editor excerpt =="
if [ -f apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx | sed -n '1,120p'
fi

echo
echo "== shade/Admin Field component definitions =="
rg -n "export .*FunctionComponent.*Field|function Field|const Field|FieldDescription|FieldError|FieldLabel" -S --glob '!node_modules' --glob '!dist' --glob '!build' . || true

Repository: TryGhost/Ghost

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate shade field source files =="
git ls-files | rg 'packages/.*shade.*(field|input).*\.(ts|tsx)$|packages/.*shade/.*Field.*\.(ts|tsx)$|packages/.*shade/components.*\.(ts|tsx)$' | head -200

echo
echo "== focused component definitions =="
rg -n "const Field|function Field|export .*Field|type Field|interface Field|const FieldDescription|function FieldDescription|const FieldError|function FieldError" -S --glob '!node_modules' --glob '!dist' --glob '!build' packages apps/admin-x-settings | head -200

Repository: TryGhost/Ghost

Length of output: 190


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate shade package =="
git ls-files | rg '(^|/)`@tryghost/shade`|packages/gh-ui|packages/shade|shade' | head -200 || true

echo
echo "== precise focused definitions in repo =="
rg -n "^const Field|^function Field|export .*Field|type Field|interface Field|const FieldDescription|function FieldDescription|const FieldError|function FieldError" \
  -S \
  --glob '!node_modules' \
  --glob '!dist' \
  --glob '!build' \
  packages/ `@tryghost` 2>/dev/null | head -300 || true

echo
echo "== target usages without surrounding files =="
python3 - <<'PY'
import re, pathlib
for p in [
    pathlib.Path('apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx'),
    pathlib.Path('apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx'),
]:
    if not p.exists():
        print('MISSING', p)
        continue
    text = p.read_text()
    print(f'\n== PREAMBLE {p} ==')
    for i,line in enumerate(text.splitlines()[:15],1):
        print(f'{i:3} {line}')
    print('\nINPUTS/MESSAGES RELATIONS:')
    for m in re.finditer(r'<(Input|FieldDescription|FieldError)[^>]*>', text, re.S):
        tag,mstart, mend = m.group(1), m.start(), m.end()
        line = text[:mstart].count('\n')+1
        body = re.sub(r'\s+', ' ', m.group(0)).strip()
        print(f'{tag}: line {line}: {body}')
PY

Repository: TryGhost/Ghost

Length of output: 13834


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== field source =="
nl -ba apps/shade/src/components/ui/field.tsx 2>/dev/null | sed -n '1,220p' || true

echo
echo "== field component export/listings =="
rg -n "export \\{.*Field|FieldDescription|FieldError|FieldLabel" apps/shade/src/components/ui/field.tsx apps/shade/src/components.ts apps/shade/src -g 'components.ts' -S | head -120

Repository: TryGhost/Ghost

Length of output: 644


🏁 Script executed:

#!/bin/bash
set -euo pipefail

awk '{printf "%6d\t%s\n", NR, $0}' apps/shade/src/components/ui/field.tsx | sed -n '1,260p'

Repository: TryGhost/Ghost

Length of output: 8859


Wire supporting messages to their controls.

FieldDescription and FieldError render as unlinked supporting text; give them stable IDs and pass the message IDs from the associated inputs using aria-describedby for descriptions and aria-errormessage for errors.

  • apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx#L47-L51
  • apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx#L32-L50
📍 Affects 2 files
  • apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx#L47-L51 (this comment)
  • apps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsx#L32-L50
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsx`
around lines 47 - 51, Wire supporting messages to their controls in
theme-setting.tsx lines 47-51 and navigation-item-editor.tsx lines 32-50: assign
stable IDs to each FieldDescription and FieldError, then pass the applicable IDs
from the associated inputs through aria-describedby and aria-errormessage
respectively, preserving existing conditional rendering and validation behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant