docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the single textarea on /admin/settings with a parsed JSONC tree of typed widgets. Each key shows its leading /* */ or // comment as inline help. Saves round-trip through jsonc-parser's modify() so untouched bytes — comments, key order, ${ENV:default} placeholders — survive intact. A "Raw" mode preserves the existing textarea as a fallback.
Architecture: Client-side only. The store holds the raw file text (single source of truth). Form view re-parses the text on each render via jsonc-parser.parseTree. Widget edits call modify(text, path, value) + applyEdits and write the result back to the store. Save sends the resulting text to the server through the existing settingsSocket. Server contract is unchanged.
Tech Stack: React 19, Zustand, react-i18next, jsonc-parser (new dep), Playwright for verification.
Branch: takeover/7666-admin-settings-editor on johnmclear/etherpad-lite (PR #7709). Rebase before starting if upstream develop has moved.
Reference spec: docs/superpowers/specs/2026-05-09-admin-settings-parsed-view-design.md.
Create:
admin/src/components/settings/jsoncEdit.ts — thin wrapper around jsonc-parser.modify + applyEdits.admin/src/components/settings/comments.ts — pure helpers: extract leading + trailing comment text for a given AST node from the source string.admin/src/components/settings/envPill.ts — detect ${VAR:default} literals from a string node's raw slice.admin/src/components/settings/CommentLabel.tsx — renders leading comment as muted help text under a key.admin/src/components/settings/ParseErrorBanner.tsx — renders parse-error notice + "Switch to raw" button.admin/src/components/settings/widgets/StringInput.tsxadmin/src/components/settings/widgets/NumberInput.tsxadmin/src/components/settings/widgets/BooleanToggle.tsxadmin/src/components/settings/widgets/NullChip.tsxadmin/src/components/settings/widgets/EnvPill.tsxadmin/src/components/settings/widgets/ObjectGroup.tsxadmin/src/components/settings/widgets/ArrayGroup.tsxadmin/src/components/settings/JsoncNode.tsx — dispatches a node to the right widget.admin/src/components/settings/FormView.tsx — top-level form: parse text, render tree, surface ParseErrorBanner.admin/src/components/settings/ModeToggle.tsx — segmented control: Form | Raw.Modify:
admin/package.json — add jsonc-parser.admin/src/pages/SettingsPage.tsx — restructure into ModeToggle + FormView/RawView shell.admin/src/App.css — append styles for tree, group, pill, banner, mode-toggle.src/locales/en.json — new i18n keys.src/tests/frontend-new/admin-spec/adminsettings.spec.ts — new Playwright specs (the file already exists with the regression specs from the previous commit).jsonc-parser dependency and scaffold the directoryFiles:
Modify: admin/package.json
Create: admin/src/components/settings/.gitkeep (ensures the directory exists; remove once a real file lands)
Step 1: Install jsonc-parser in admin/
cd admin && pnpm add jsonc-parser@^3.3.1
Expected output: + jsonc-parser 3.3.1. package.json and the lockfile both update.
cd admin && node -e "import('jsonc-parser').then(m => console.log(Object.keys(m).sort().slice(0,8)))"
Expected output includes: applyEdits, findNodeAtLocation, getNodePath, modify, parse, parseTree.
git add admin/package.json ../pnpm-lock.yaml
git commit -m "admin(settings): add jsonc-parser dep"
envPill.ts and comments.ts helpersThese are pure functions. No unit-test runner is configured in admin/, so we exercise them indirectly via Playwright. Keep them strictly testable: pure functions, no React.
Files:
Create: admin/src/components/settings/envPill.ts
Create: admin/src/components/settings/comments.ts
Step 1: Write envPill.ts
// admin/src/components/settings/envPill.ts
//
// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw
// slice of a string node. The slice INCLUDES the surrounding quotes,
// because jsonc-parser exposes node.offset/length over the whole literal.
export type EnvPlaceholder = {
variable: string;
defaultValue: string | null;
};
const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/;
export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => {
const m = RE.exec(rawSlice);
if (!m) return null;
return {
variable: m[1],
defaultValue: m[2] ?? null,
};
};
comments.ts// admin/src/components/settings/comments.ts
//
// Given the source text and a property's `keyOffset` (jsonc-parser's
// Node.offset for the property node), extract:
// - `leading`: the contiguous run of `/* */` or `//` comments
// immediately above the key. At most one blank line is allowed
// between the comment block and the key.
// - `trailing`: a single `// ...` or `/* ... */` on the same line
// as the value, after any trailing comma.
export type AdjacentComments = {
leading: string;
trailing: string;
};
const LINE_BREAK = /\r?\n/;
const stripCommentMarkers = (raw: string): string => {
// raw is a concatenation of comment tokens separated by newlines.
// Drop /* */ and // markers and trim each line.
return raw
.split(LINE_BREAK)
.map(line => line
.replace(/^\s*\/\*+/, '')
.replace(/\*+\/\s*$/, '')
.replace(/^\s*\*\s?/, '')
.replace(/^\s*\/\/\s?/, '')
.trim())
.filter(line => line.length > 0)
.join(' ');
};
const findLeading = (text: string, keyOffset: number): string => {
// Walk backwards from keyOffset to the start of the line containing it.
const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1;
let cursor = lineStart;
let blankLineSeen = false;
const collected: string[] = [];
while (cursor > 0) {
// Look at the previous line.
const prevLineEnd = cursor - 1; // the '\n' before our cursor's line
const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1;
const line = text.slice(prevLineStart, prevLineEnd);
const trimmed = line.trim();
if (trimmed === '') {
if (blankLineSeen) break;
blankLineSeen = true;
cursor = prevLineStart;
continue;
}
const isComment =
trimmed.startsWith('//') ||
trimmed.startsWith('/*') ||
trimmed.startsWith('*') ||
trimmed.endsWith('*/');
if (!isComment) break;
collected.unshift(line);
cursor = prevLineStart;
}
return stripCommentMarkers(collected.join('\n'));
};
const findTrailing = (text: string, valueOffset: number, valueLength: number): string => {
// Look from end-of-value to end-of-line for a single comment.
const lineEnd = text.indexOf('\n', valueOffset + valueLength);
const slice = text.slice(valueOffset + valueLength, lineEnd === -1 ? text.length : lineEnd);
const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice);
return m ? stripCommentMarkers(m[1]) : '';
};
export const extractAdjacentComments = (
text: string,
keyOffset: number,
valueOffset: number,
valueLength: number,
): AdjacentComments => ({
leading: findLeading(text, keyOffset),
trailing: findTrailing(text, valueOffset, valueLength),
});
cd admin && npx tsc --noEmit
Expected: exit 0.
git add admin/src/components/settings/envPill.ts admin/src/components/settings/comments.ts
git commit -m "admin(settings): pure helpers for env pills and comment extraction"
jsoncEdit.ts save helperFiles:
Create: admin/src/components/settings/jsoncEdit.ts
Step 1: Write the wrapper
// admin/src/components/settings/jsoncEdit.ts
import { applyEdits, modify, type JSONPath } from 'jsonc-parser';
const FORMATTING = {
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const },
};
export const editJsonc = (text: string, path: JSONPath, value: unknown): string => {
const edits = modify(text, path, value, FORMATTING);
return edits.length === 0 ? text : applyEdits(text, edits);
};
cd admin && npx tsc --noEmit
Expected: exit 0.
git add admin/src/components/settings/jsoncEdit.ts
git commit -m "admin(settings): editJsonc wrapper around jsonc-parser modify"
Each leaf takes { value, path, onChange }. onChange(newValue) is wired by the parent (JsoncNode) to call editJsonc and push the new text into the store. Leaves are presentational only.
Files:
Create: admin/src/components/settings/widgets/StringInput.tsx
Create: admin/src/components/settings/widgets/NumberInput.tsx
Create: admin/src/components/settings/widgets/BooleanToggle.tsx
Create: admin/src/components/settings/widgets/NullChip.tsx
Create: admin/src/components/settings/widgets/EnvPill.tsx
Step 1: widgets/StringInput.tsx
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: string;
path: JSONPath;
onChange: (next: string) => void;
};
export const StringInput = ({ value, path, onChange }: Props) => (
<input
type="text"
className="settings-widget settings-widget-string"
data-testid={`field-${path.join('.')}`}
value={value}
spellCheck={false}
onChange={e => onChange(e.target.value)}
/>
);
widgets/NumberInput.tsxBad numeric input must not corrupt the file text. We hold the raw input string in local state and only call onChange when it parses to a finite number.
import { useState } from 'react';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: number;
path: JSONPath;
onChange: (next: number) => void;
};
export const NumberInput = ({ value, path, onChange }: Props) => {
const [draft, setDraft] = useState(String(value));
const [invalid, setInvalid] = useState(false);
return (
<input
type="text"
inputMode="decimal"
className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
data-testid={`field-${path.join('.')}`}
value={draft}
onChange={e => {
const next = e.target.value;
setDraft(next);
const parsed = Number(next);
if (next.trim() !== '' && Number.isFinite(parsed)) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
}}
/>
);
};
widgets/BooleanToggle.tsxThe repo already uses @radix-ui/react-switch. Use it.
import * as Switch from '@radix-ui/react-switch';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: boolean;
path: JSONPath;
onChange: (next: boolean) => void;
};
export const BooleanToggle = ({ value, path, onChange }: Props) => (
<Switch.Root
checked={value}
onCheckedChange={onChange}
className="settings-widget settings-widget-boolean"
data-testid={`field-${path.join('.')}`}
>
<Switch.Thumb className="settings-widget-boolean-thumb" />
</Switch.Root>
);
widgets/NullChip.tsximport type { JSONPath } from 'jsonc-parser';
type Props = { path: JSONPath };
export const NullChip = ({ path }: Props) => (
<span
className="settings-widget settings-widget-null"
data-testid={`field-${path.join('.')}`}
>null</span>
);
widgets/EnvPill.tsximport { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
};
export const EnvPill = ({ placeholder, path }: Props) => {
const { t } = useTranslation();
return (
<span
className="settings-widget settings-widget-env"
role="note"
title={t('admin_settings.env_pill.tooltip')}
data-testid={`env-${path.join('.')}`}
>
<span className="settings-widget-env-icon" aria-hidden>ⓔ</span>
<span className="settings-widget-env-name">{placeholder.variable}</span>
{placeholder.defaultValue !== null && (
<span className="settings-widget-env-default">
{' '}default: <code>{placeholder.defaultValue}</code>
</span>
)}
</span>
);
};
cd admin && npx tsc --noEmit
Expected: exit 0.
git add admin/src/components/settings/widgets
git commit -m "admin(settings): leaf widgets (string, number, bool, null, env pill)"
ObjectGroup and ArrayGroup use the native <details>/<summary> for collapsibility (a11y comes for free). JsoncNode is the dispatcher: given an AST node it picks the right widget.
Files:
Create: admin/src/components/settings/widgets/ObjectGroup.tsx
Create: admin/src/components/settings/widgets/ArrayGroup.tsx
Create: admin/src/components/settings/JsoncNode.tsx
Create: admin/src/components/settings/CommentLabel.tsx
Step 1: CommentLabel.tsx
type Props = {
leading: string;
trailing: string;
htmlId: string;
};
export const CommentLabel = ({ leading, trailing, htmlId }: Props) => {
if (!leading && !trailing) return null;
return (
<div className="settings-comment" id={htmlId}>
{leading && <span className="settings-comment-leading">{leading}</span>}
{trailing && <span className="settings-comment-trailing"> // {trailing}</span>}
</div>
);
};
widgets/ObjectGroup.tsximport type { ReactNode } from 'react';
import type { JSONPath } from 'jsonc-parser';
type Props = {
path: JSONPath;
childCount: number;
children: ReactNode;
};
export const ObjectGroup = ({ path, childCount, children }: Props) => (
<details
className="settings-group settings-group-object"
data-testid={`group-${path.join('.') || 'root'}`}
open
>
<summary>{`{ ${childCount} ${childCount === 1 ? 'key' : 'keys'} }`}</summary>
<div className="settings-group-body">{children}</div>
</details>
);
widgets/ArrayGroup.tsximport type { ReactNode } from 'react';
import type { JSONPath } from 'jsonc-parser';
type Props = {
path: JSONPath;
childCount: number;
children: ReactNode;
};
export const ArrayGroup = ({ path, childCount, children }: Props) => (
<details
className="settings-group settings-group-array"
data-testid={`group-${path.join('.') || 'root'}`}
open
>
<summary>{`[ ${childCount} ${childCount === 1 ? 'item' : 'items'} ]`}</summary>
<div className="settings-group-body">{children}</div>
</details>
);
JsoncNode.tsxThis is the only place that decides which widget renders. It receives a Node (from parseTree), the source text (so it can pull raw slices for env detection and comments), and an onEdit callback.
import type { JSONPath, Node } from 'jsonc-parser';
import { getNodePath } from 'jsonc-parser';
import { CommentLabel } from './CommentLabel';
import { extractAdjacentComments } from './comments';
import { matchEnvPlaceholder } from './envPill';
import { StringInput } from './widgets/StringInput';
import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
import { ObjectGroup } from './widgets/ObjectGroup';
import { ArrayGroup } from './widgets/ArrayGroup';
type Props = {
/** The value node (not the property node). */
node: Node;
/** The property node, when this value is the value-side of `"key": value`. */
property?: Node;
text: string;
onEdit: (path: JSONPath, value: unknown) => void;
};
export const JsoncNode = ({ node, property, text, onEdit }: Props) => {
const path = getNodePath(node);
// Comment lookup is based on the property node when present (object child),
// otherwise the value node directly (array element / root).
const anchor = property ?? node;
const { leading, trailing } = extractAdjacentComments(
text,
anchor.offset,
node.offset,
node.length,
);
const commentId = `settings-comment-${path.join('.') || 'root'}`;
const wrap = (label: React.ReactNode, control: React.ReactNode) => (
<div className="settings-row" aria-describedby={commentId}>
{label && <span className="settings-key">{label}</span>}
<span className="settings-value">{control}</span>
<CommentLabel leading={leading} trailing={trailing} htmlId={commentId} />
</div>
);
// Property name for object children:
const keyLabel =
property?.type === 'property' && property.children?.[0]?.type === 'string'
? String(property.children[0].value)
: null;
if (node.type === 'object') {
return wrap(
keyLabel,
<ObjectGroup path={path} childCount={node.children?.length ?? 0}>
{(node.children ?? []).map((prop, i) => {
const valueNode = prop.children?.[1];
if (!valueNode) return null;
return (
<JsoncNode
key={i}
node={valueNode}
property={prop}
text={text}
onEdit={onEdit}
/>
);
})}
</ObjectGroup>,
);
}
if (node.type === 'array') {
return wrap(
keyLabel,
<ArrayGroup path={path} childCount={node.children?.length ?? 0}>
{(node.children ?? []).map((child, i) => (
<JsoncNode key={i} node={child} text={text} onEdit={onEdit} />
))}
</ArrayGroup>,
);
}
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
const env = matchEnvPlaceholder(raw);
if (env) return wrap(keyLabel, <EnvPill placeholder={env} path={path} />);
return wrap(
keyLabel,
<StringInput
value={String(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>,
);
}
if (node.type === 'number') {
return wrap(
keyLabel,
<NumberInput
value={Number(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>,
);
}
if (node.type === 'boolean') {
return wrap(
keyLabel,
<BooleanToggle
value={Boolean(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>,
);
}
if (node.type === 'null') {
return wrap(keyLabel, <NullChip path={path} />);
}
// 'property' nodes are handled by their parent object branch above.
return null;
};
cd admin && npx tsc --noEmit
Expected: exit 0.
git add admin/src/components/settings
git commit -m "admin(settings): group widgets, JsoncNode dispatcher, CommentLabel"
FormView, ParseErrorBanner, ModeToggleFiles:
Create: admin/src/components/settings/FormView.tsx
Create: admin/src/components/settings/ParseErrorBanner.tsx
Create: admin/src/components/settings/ModeToggle.tsx
Step 1: ParseErrorBanner.tsx
import { Trans } from 'react-i18next';
type Props = {
message: string;
onSwitchToRaw: () => void;
};
export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => (
<div className="settings-parse-error" role="alert" data-testid="parse-error-banner">
<strong><Trans i18nKey="admin_settings.parse_error.title" /></strong>
<pre className="settings-parse-error-detail">{message}</pre>
<button type="button" onClick={onSwitchToRaw} data-testid="parse-error-switch-raw">
<Trans i18nKey="admin_settings.parse_error.cta" />
</button>
</div>
);
FormView.tsximport { parseTree, type JSONPath, type ParseError } from 'jsonc-parser';
import { useStore } from '../../store/store';
import { editJsonc } from './jsoncEdit';
import { JsoncNode } from './JsoncNode';
import { ParseErrorBanner } from './ParseErrorBanner';
type Props = {
onSwitchToRaw: () => void;
};
const formatErrors = (errors: ParseError[]): string =>
errors.length === 0
? ''
: errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n');
const ParseErrorMessage: Record<number, string> = {
1: 'Invalid symbol',
2: 'Invalid number format',
3: 'Property name expected',
4: 'Value expected',
5: 'Colon expected',
6: 'Comma expected',
7: 'Closing brace expected',
8: 'Closing bracket expected',
9: 'End of file expected',
16: 'Unexpected end of comment',
17: 'Unexpected end of string',
18: 'Unexpected end of number',
19: 'Invalid unicode',
20: 'Invalid escape character',
21: 'Invalid character',
};
export const FormView = ({ onSwitchToRaw }: Props) => {
const text = useStore(s => s.settings) ?? '';
const errors: ParseError[] = [];
const tree = parseTree(text, errors, { allowTrailingComma: true });
const onEdit = (path: JSONPath, value: unknown) => {
useStore.getState().setSettings(editJsonc(text, path, value));
};
if (!tree || errors.length > 0) {
return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
}
return (
<div className="settings-form" data-testid="settings-form-view">
<JsoncNode node={tree} text={text} onEdit={onEdit} />
</div>
);
};
ModeToggle.tsximport { Trans } from 'react-i18next';
export type Mode = 'form' | 'raw';
type Props = {
mode: Mode;
onChange: (mode: Mode) => void;
};
export const ModeToggle = ({ mode, onChange }: Props) => (
<div className="settings-mode-toggle" role="tablist" aria-label="Editor mode">
<button
type="button"
role="tab"
aria-selected={mode === 'form'}
data-testid="mode-toggle-form"
className={mode === 'form' ? 'active' : ''}
onClick={() => onChange('form')}
>
<Trans i18nKey="admin_settings.mode.form" />
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'raw'}
data-testid="mode-toggle-raw"
className={mode === 'raw' ? 'active' : ''}
onClick={() => onChange('raw')}
>
<Trans i18nKey="admin_settings.mode.raw" />
</button>
</div>
);
cd admin && npx tsc --noEmit
Expected: exit 0.
git add admin/src/components/settings
git commit -m "admin(settings): FormView, ParseErrorBanner, ModeToggle"
SettingsPage.tsxThe page becomes a shell that toggles between FormView and the existing raw textarea. Save / Validate / Restart and the Prettify feature flag stay where they were in the previous commit.
Files:
Modify: admin/src/pages/SettingsPage.tsx
Step 1: Replace the file
import React, { useState } from 'react';
import { useStore } from '../store/store';
import { isJSONClean, cleanComments } from '../utils/utils';
import { Trans, useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
import { FormView } from '../components/settings/FormView';
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
const TAB_INDENT = ' ';
export const SettingsPage = () => {
const { t } = useTranslation();
const settingsSocket = useStore(state => state.settingsSocket);
const settings = useStore(state => state.settings) ?? '';
const [mode, setMode] = useState<Mode>('form');
const [exposeExperimental] = useState(false);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Tab') return;
e.preventDefault();
const target = e.currentTarget;
const { selectionStart, selectionEnd, value } = target;
const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
useStore.getState().setSettings(next);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
});
};
const showToast = (titleKey: string, success: boolean) => {
useStore.getState().setToastState({ open: true, title: t(titleKey), success });
};
const testJSON = () => {
if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
else showToast('admin_settings.toast.validation_failed', false);
};
const prettifyJSON = () => {
try {
const obj = JSON.parse(cleanComments(settings) ?? '');
if (window.confirm(t('admin_settings.prettify_confirm'))) {
useStore.getState().setSettings(JSON.stringify(obj, null, 2));
}
} catch {
showToast('admin_settings.toast.prettify_failed', false);
}
};
const handleSave = () => {
if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
settingsSocket.emit('saveSettings', settings);
showToast('admin_settings.toast.saved', true);
};
return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
<ModeToggle mode={mode} onChange={setMode} />
{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)
}
<div className="settings-button-bar">
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
<IconButton
className="settingsButton"
data-testid="restart-etherpad-button"
icon={<RotateCw />}
title={<Trans i18nKey="admin_settings.current_restart.value" />}
onClick={() => settingsSocket?.emit('restartServer')}
/>
</div>
<div className="settings-links">
<a rel="noopener noreferrer" target="_blank" href="//github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-prod" />
</a>
<a rel="noopener noreferrer" target="_blank" href="//github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-devel" />
</a>
</div>
</div>
);
};
cd admin && npx tsc --noEmit
Expected: exit 0.
cd admin && npx vite build --outDir ../src/templates/admin --emptyOutDir
Expected: build completes; the only warning may be the existing chunk-size warning.
git add admin/src/pages/SettingsPage.tsx
git commit -m "admin(settings): toggle FormView and raw textarea from SettingsPage"
Files:
Modify: admin/src/App.css
Step 1: Append the styles
/* --- mode toggle --- */
.settings-mode-toggle {
display: inline-flex;
border: 1px solid #444;
border-radius: 6px;
overflow: hidden;
margin-bottom: 12px;
}
.settings-mode-toggle button {
padding: 6px 14px;
border: 0;
background: transparent;
color: #d4d4d4;
cursor: pointer;
}
.settings-mode-toggle button.active {
background: #007acc;
color: #fff;
}
/* --- form tree --- */
.settings-form {
font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 13px;
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border: 1px solid #333;
border-radius: 4px;
}
.settings-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.settings-key {
font-weight: 600;
color: #9cdcfe;
}
.settings-value {
display: inline-flex;
align-items: center;
gap: 8px;
}
.settings-comment {
font-style: italic;
color: #6a9955;
white-space: pre-wrap;
}
.settings-comment-trailing {
color: #808080;
font-style: normal;
}
/* --- group widgets --- */
.settings-group > summary {
cursor: pointer;
color: #c586c0;
}
.settings-group-body {
padding-left: 16px;
border-left: 1px solid #333;
margin-left: 4px;
}
/* --- leaf widgets --- */
.settings-widget-string,
.settings-widget-number {
background: #2d2d2d;
color: #d4d4d4;
border: 1px solid #444;
border-radius: 3px;
padding: 2px 6px;
font-family: inherit;
font-size: inherit;
min-width: 220px;
}
.settings-widget-number.invalid {
border-color: #ce5050;
}
.settings-widget-null {
color: #569cd6;
font-style: italic;
}
.settings-widget-env {
display: inline-flex;
align-items: center;
gap: 6px;
background: #2d2d4d;
color: #d4d4d4;
border: 1px dashed #5577aa;
border-radius: 12px;
padding: 1px 8px;
}
.settings-widget-env code {
background: transparent;
color: #ce9178;
}
/* --- parse error --- */
.settings-parse-error {
border: 1px solid #ce5050;
background: #3a1f1f;
color: #fdd;
padding: 12px;
border-radius: 4px;
}
.settings-parse-error-detail {
white-space: pre-wrap;
font-family: inherit;
}
.settings-parse-error button {
margin-top: 8px;
background: #ce5050;
color: #fff;
border: 0;
padding: 6px 12px;
border-radius: 3px;
cursor: pointer;
}
git add admin/src/App.css
git commit -m "admin(settings): styles for tree, widgets, env pill, parse error"
Files:
Modify: src/locales/en.json
Step 1: Add keys after the existing admin_settings.* block
Insert the following lines next to the other admin_settings.toast.* keys (already added in the previous commit):
"admin_settings.mode.form": "Form",
"admin_settings.mode.raw": "Raw",
"admin_settings.parse_error.title": "Cannot parse settings.json",
"admin_settings.parse_error.cta": "Switch to raw to edit",
"admin_settings.env_pill.tooltip": "Environment variable. Edit in raw mode.",
node -e "JSON.parse(require('fs').readFileSync('src/locales/en.json','utf8'))"
Expected: no output, exit 0.
git add src/locales/en.json
git commit -m "admin(settings): i18n keys for form mode, parse error, env pill"
These are the verification gate. Write all of them, run, see failures unrelated to logic (e.g. selectors), tighten until they pass.
Files:
src/tests/frontend-new/admin-spec/adminsettings.spec.tsThe existing file already contains: Are Settings visible…, preserves /* */ comments after save round-trip, validate button toasts…, restart works. Append these inside test.describe('admin settings', …):
comment is rendered as help text spectest('form view renders leading comment as help text for known key', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.getByTestId('mode-toggle-form').click();
await page.waitForSelector('[data-testid="settings-form-view"]');
// settings.json ships with a leading comment above `dbType` (or `title`).
// Assert that *some* row exposes a non-empty `.settings-comment-leading`.
const firstComment = page.locator('.settings-comment-leading').first();
await expect(firstComment).toBeVisible({timeout: 10000});
expect((await firstComment.textContent())?.trim().length ?? 0).toBeGreaterThan(0);
});
editing a string field round-trips spectest('editing title via form input round-trips through save', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
const original = await raw.inputValue();
await page.getByTestId('mode-toggle-form').click();
const titleField = page.getByTestId('field-title');
await expect(titleField).toBeVisible({timeout: 10000});
await titleField.fill('Etherpad-Form-Edit');
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.getByTestId('mode-toggle-raw').click();
const after = await page.getByTestId('settings-raw-textarea').inputValue();
expect(after).toContain('"title": "Etherpad-Form-Edit"');
// Comments above title must survive
const titleIdx = after.indexOf('"title"');
expect(after.slice(0, titleIdx)).toMatch(/\/\*[\s\S]*?\*\//);
// Restore
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
boolean toggle round-trips spectest('boolean toggle round-trips through save', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.getByTestId('mode-toggle-raw').click();
const original = await page.getByTestId('settings-raw-textarea').inputValue();
await page.getByTestId('mode-toggle-form').click();
const toggle = page.getByTestId('field-requireAuthentication');
await expect(toggle).toBeVisible({timeout: 10000});
const before = await toggle.getAttribute('aria-checked');
await toggle.click();
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
await page.reload();
await page.getByTestId('mode-toggle-form').click();
const after = await page.getByTestId('field-requireAuthentication').getAttribute('aria-checked');
expect(after).not.toEqual(before);
// Restore
await page.getByTestId('mode-toggle-raw').click();
await page.getByTestId('settings-raw-textarea').fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
env placeholder renders as read-only pill spectest('env placeholder renders as read-only pill (no input)', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.getByTestId('mode-toggle-form').click();
await page.waitForSelector('[data-testid="settings-form-view"]');
// settings.json ships ${SSO_ISSUER:http://localhost:9001} on sso.issuer
const pill = page.getByTestId('env-sso.issuer');
await expect(pill).toBeVisible({timeout: 10000});
// No <input> exists for that path
await expect(page.getByTestId('field-sso.issuer')).toHaveCount(0);
});
raw → form on broken JSON shows banner spectest('toggling form on broken raw JSON shows parse error banner', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
const original = await raw.inputValue();
await raw.fill('{ "broken":');
await page.getByTestId('mode-toggle-form').click();
await expect(page.getByTestId('parse-error-banner')).toBeVisible();
// CTA returns to raw view
await page.getByTestId('parse-error-switch-raw').click();
await expect(raw).toBeVisible();
// Restore
await raw.fill(original);
await page.getByTestId('save-settings-button').click();
await expect(page.locator('.ToastRootSuccess')).toBeVisible({timeout: 5000});
});
cd src && npx playwright test admin-spec/adminsettings.spec.ts --reporter=line
Expected: all specs pass. (Do NOT use --headed — see CLAUDE memory.)
If a spec fails because settings.json doesn't ship with the assumed key (e.g. dbType, title, sso.issuer), update the spec to a key that is present in the shipped settings.json. Do not change widget code to satisfy a spec that's looking at the wrong key.
git add src/tests/frontend-new/admin-spec/adminsettings.spec.ts
git commit -m "admin(settings): playwright specs for form view, env pill, parse error, round-trip"
cd src && NODE_ENV=development node --require tsx/cjs node/server.ts
Login at http://localhost:9001/admin/login as admin / changeme1, go to /admin/settings. Verify by hand:
title, click Save, reload — change persists, comments survive.Ctrl-C if you don't want to wait).git push fork takeover/7666-admin-settings-editor
gh pr edit 7709 --repo ether/etherpad \
--title "admin: parsed JSONC settings editor (takes over #7666, closes #7603)" \
--body-file <(cat <<'EOF'
Replaces the textarea on `/admin/settings` with a parsed JSONC tree:
each key is rendered with the right typed widget (string input, number,
toggle, env pill, collapsible object/array) and its leading `/* */`
comment surfaces as inline help. A "Raw" mode toggle keeps the existing
textarea editor behind it for power users and structural edits.
Round-trip is byte-identical for untouched regions: edits go through
`jsonc-parser`'s `modify()` so comments, key order, whitespace, and
`${ENV:default}` placeholders all survive.
Takes over #7666 (original author AWOL). Closes #7603.
## Spec / plan
- Design: `docs/superpowers/specs/2026-05-09-admin-settings-parsed-view-design.md`
- Plan: `docs/superpowers/plans/2026-05-09-admin-settings-parsed-view.md`
## Tests
- Existing specs (comments-preserved, validate, restart) continue to
pass; restart now keys off `data-testid` instead of `.nth(1)`.
- New: form renders comment as help text; string round-trips through
save; boolean toggle round-trips; env placeholder renders as pill;
broken raw JSON surfaces a parse-error banner with a "Switch to raw"
CTA.
## Out of scope (follow-ups)
- Add/remove keys from the form (raw mode is the escape hatch).
- Editing `${VAR:default}` placeholders in form mode.
- Schema-driven help text.
## Semver
patch — admin UI only, no API or settings-file format changes.
EOF
)
gh pr comment 7709 --repo ether/etherpad --body "/review"
(Skipped — already running in this session at http://localhost:9001/admin/settings)
After completing all tasks, before declaring done:
cd admin && npx tsc --noEmit exits 0.cd admin && npx vite build --outDir ../src/templates/admin --emptyOutDir succeeds.cd src && npx playwright test admin-spec/adminsettings.spec.ts --reporter=line is green.git diff develop --stat shows only files in admin/src/, admin/package.json, pnpm-lock.yaml, src/locales/en.json, src/templates/admin/ (build output), src/tests/frontend-new/admin-spec/adminsettings.spec.ts, src/tests/frontend-new/helper/adminhelper.ts, and the docs/superpowers/ files.console.log or debugger left in admin/src/components/settings/./review posted.