Moved settings text fields to Shade - #29559
Conversation
no ref Settings should use Shade primitives directly so the legacy design system can be removed without introducing another settings-only abstraction.
WalkthroughThe PR removes the Admin X Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
| 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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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
📒 Files selected for processing (46)
.agents/skills/shade-input-surface-recipe/SKILL.mdapps/admin-x-design-system/src/global/form/form.stories.tsxapps/admin-x-design-system/src/global/form/text-field.stories.tsxapps/admin-x-design-system/src/global/form/text-field.tsxapps/admin-x-design-system/src/index.tsapps/admin-x-settings/src/app.tsxapps/admin-x-settings/src/components/settings/advanced/integrations/add-integration-modal.tsxapps/admin-x-settings/src/components/settings/advanced/integrations/custom-integration-modal.tsxapps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsxapps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsxapps/admin-x-settings/src/components/settings/advanced/integrations/webhook-modal.tsxapps/admin-x-settings/src/components/settings/email/newsletters/add-newsletter-modal.tsxapps/admin-x-settings/src/components/settings/email/newsletters/newsletter-detail-modal.tsxapps/admin-x-settings/src/components/settings/general/invite-user-modal.tsxapps/admin-x-settings/src/components/settings/general/publication-language.tsxapps/admin-x-settings/src/components/settings/general/seo-meta.tsxapps/admin-x-settings/src/components/settings/general/social-accounts.tsxapps/admin-x-settings/src/components/settings/general/title-and-description.tsxapps/admin-x-settings/src/components/settings/general/users/change-password-form.tsxapps/admin-x-settings/src/components/settings/general/users/profile-tab.tsxapps/admin-x-settings/src/components/settings/general/users/social-links-tab.tsxapps/admin-x-settings/src/components/settings/growth/offers/add-offer-modal.tsxapps/admin-x-settings/src/components/settings/growth/offers/edit-offer-modal.tsxapps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsxapps/admin-x-settings/src/components/settings/growth/offers/offer-success.tsxapps/admin-x-settings/src/components/settings/growth/recommendations/add-recommendation-modal.tsxapps/admin-x-settings/src/components/settings/growth/recommendations/recommendation-description-form.tsxapps/admin-x-settings/src/components/settings/growth/tips-and-donations.tsxapps/admin-x-settings/src/components/settings/membership/access.tsxapps/admin-x-settings/src/components/settings/membership/custom-fields/custom-field-modal.tsxapps/admin-x-settings/src/components/settings/membership/member-emails/test-email-dropdown.tsxapps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-modal.tsxapps/admin-x-settings/src/components/settings/membership/portal/account-page.tsxapps/admin-x-settings/src/components/settings/membership/portal/look-and-feel.tsxapps/admin-x-settings/src/components/settings/membership/portal/portal-links.tsxapps/admin-x-settings/src/components/settings/membership/portal/transistor-settings.tsxapps/admin-x-settings/src/components/settings/membership/stripe/stripe-connect-modal.tsxapps/admin-x-settings/src/components/settings/membership/tiers/tier-detail-modal.tsxapps/admin-x-settings/src/components/settings/site/design-and-branding/theme-setting.tsxapps/admin-x-settings/src/components/settings/site/navigation/navigation-item-editor.tsxapps/admin-x-settings/src/components/settings/site/theme/theme-editor-input-modal.tsxapps/shade/src/components/ui/input-group.tsxapps/shade/src/components/ui/input-surface.stories.tsxapps/shade/src/components/ui/input-surface.tsapps/shade/tailwind.theme.cssapps/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
| className='[&_:where(input)]:h-[var(--control-height)] [&_:where(input)]:border-transparent [&_:where(input)]:bg-muted' | ||
| marginBottom={false} | ||
| marginTop={false} | ||
| > |
There was a problem hiding this comment.
📐 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-L130apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx#L84-L84apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx#L63-L63apps/admin-x-settings/src/components/settings/general/title-and-description.tsx#L69-L69apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx#L17-L17apps/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.
| <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> |
There was a problem hiding this comment.
🎯 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 -uRepository: 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 -200Repository: 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 -300Repository: 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
doneRepository: 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 || trueRepository: 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-L135apps/admin-x-settings/src/components/settings/advanced/integrations/first-promoter-modal.tsx#L93-L104apps/admin-x-settings/src/components/settings/advanced/integrations/slack-modal.tsx#L64-L76apps/admin-x-settings/src/components/settings/general/title-and-description.tsx#L70-L94apps/admin-x-settings/src/components/settings/general/users/change-password-form.tsx#L170-L184apps/admin-x-settings/src/components/settings/general/users/profile-tab.tsx#L18-L39apps/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).
| <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> |
There was a problem hiding this comment.
🎯 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.
| <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.
| <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> |
There was a problem hiding this comment.
🎯 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())}')
PYRepository: 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())}')
PYRepository: 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())))
PYRepository: 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.
| <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> |
There was a problem hiding this comment.
🎯 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.
| <Input | ||
| className='w-full border-transparent bg-muted' |
There was a problem hiding this comment.
🎯 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'> |
There was a problem hiding this comment.
🎯 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.
| <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> |
There was a problem hiding this comment.
🎯 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.
| <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.
| <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> |
There was a problem hiding this comment.
🎯 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, "")}));
JSRepository: 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.
| <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.
| <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> |
There was a problem hiding this comment.
🎯 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' . || trueRepository: 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 -200Repository: 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}')
PYRepository: 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 -120Repository: 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-L51apps/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.

What changed
TextFieldusages in settings with direct ShadeField,Input,Textarea, andInputGroupcompositionsTextFieldimplementation, story, and design-system exportsWhy
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 lintpnpm --filter @tryghost/admin-x-settings exec tsc --noEmitpnpm --filter @tryghost/admin-x-settings test:unit(22 files, 213 tests)pnpm --filter @tryghost/admin-x-design-system lintpnpm --filter @tryghost/admin-x-design-system buildpnpm --filter @tryghost/shade lintpnpm --filter @tryghost/shade test(25 files, 233 tests)pnpm --filter @tryghost/shade build