feat(dashboard): Rhai source formatter with Format button
AST-based pretty-printer: tab-indented, 100-col print width, normalized operator spacing, predictable reflow of long argument lists, comments preserved verbatim. Refuses to emit on a parse failure and returns the first error, so the Edit-tab button mirrors the JSON Format UX — inline `.error.inline` banner; doc untouched on failure. Patch bump to `0.5.1` across Cargo.toml workspace.package, the dashboard package.json, and the docs/versioning.md Current versions table. Bundle delta versus the previous build: +6 KB raw, +1.5 KB gzipped. Cumulative since the start of this work: +28 KB raw, +7.3 KB gzipped — well under the +100 KB budget. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
dashboard/src/lib/rhai/format.test.ts
Normal file
122
dashboard/src/lib/rhai/format.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { format } from './format';
|
||||
|
||||
function formatted(src: string): string {
|
||||
const r = format(src);
|
||||
if (!r.ok) throw new Error(`expected format to succeed, got: ${r.error.message}`);
|
||||
return r.text;
|
||||
}
|
||||
|
||||
describe('format — basic shape', () => {
|
||||
it('normalizes a simple let with operator spacing', () => {
|
||||
const out = formatted('let x=1+2 * 3;');
|
||||
expect(out).toBe('let x = 1 + 2 * 3;\n');
|
||||
});
|
||||
|
||||
it('renders a fn declaration with body', () => {
|
||||
const out = formatted('fn process(order,user){order.total}');
|
||||
expect(out).toBe(
|
||||
'fn process(order, user) {\n' +
|
||||
'\torder.total\n' +
|
||||
'}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('separates top-level fn decls with a blank line', () => {
|
||||
const out = formatted('fn a(){1}fn b(){2}');
|
||||
expect(out).toBe(
|
||||
'fn a() {\n\t1\n}\n\nfn b() {\n\t2\n}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders if / else if / else with blocks', () => {
|
||||
const out = formatted('if a{1}else if b{2}else{3}');
|
||||
expect(out).toBe(
|
||||
'if a {\n\t1\n} else if b {\n\t2\n} else {\n\t3\n}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an object-map literal inline when short', () => {
|
||||
const out = formatted('let o=#{a:1,b:2};');
|
||||
expect(out).toBe('let o = #{ a: 1, b: 2 };\n');
|
||||
});
|
||||
|
||||
it('renders log::info as a namespace call', () => {
|
||||
const out = formatted('log::info( "hi" );');
|
||||
expect(out).toBe('log::info("hi");\n');
|
||||
});
|
||||
|
||||
it('preserves comments verbatim before statements', () => {
|
||||
const out = formatted('// docstring\nfn process(){1}');
|
||||
expect(out).toBe(
|
||||
'// docstring\nfn process() {\n\t1\n}\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps block comments verbatim', () => {
|
||||
const out = formatted('/* keep me */ let x = 1;');
|
||||
expect(out).toContain('/* keep me */');
|
||||
expect(out).toContain('let x = 1;');
|
||||
});
|
||||
|
||||
it('emits an empty block as `{}` without padding', () => {
|
||||
const out = formatted('fn noop(){}');
|
||||
expect(out).toBe('fn noop() {}\n');
|
||||
});
|
||||
|
||||
it('preserves string literals verbatim', () => {
|
||||
const out = formatted('let s = "hello\\nworld";');
|
||||
expect(out).toBe('let s = "hello\\nworld";\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('format — reflow', () => {
|
||||
it('reflows a long argument list onto separate lines', () => {
|
||||
const src =
|
||||
'process(aaaaaaaaaa, bbbbbbbbbb, cccccccccc, dddddddddd, eeeeeeeeee, ffffffffff, gggggggggg, hhhhhhhhhh);';
|
||||
const out = formatted(src);
|
||||
// Should contain at least one newline inside the parens (multi-line).
|
||||
const callBlock = out.slice(out.indexOf('('), out.lastIndexOf(')') + 1);
|
||||
expect(callBlock).toContain('\n');
|
||||
expect(callBlock.endsWith(',\n)')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps short argument lists inline', () => {
|
||||
const out = formatted('process(1, 2, 3);');
|
||||
expect(out).toBe('process(1, 2, 3);\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('format — parse failures', () => {
|
||||
it('returns ok=false with the first parse error', () => {
|
||||
const r = format('let = ;');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(typeof r.error.message).toBe('string');
|
||||
expect(r.error.offset).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not partially rewrite when parsing fails', () => {
|
||||
const r = format('let x = 1; this is garbage');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('format — idempotent', () => {
|
||||
it('formatting twice yields the same output', () => {
|
||||
const src = `
|
||||
fn process(order,user) {
|
||||
if order.total > 100 {
|
||||
log::info("big", #{id:order.id});
|
||||
} else {
|
||||
log::info("small");
|
||||
}
|
||||
return order;
|
||||
}
|
||||
`;
|
||||
const a = formatted(src);
|
||||
const b = formatted(a);
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
});
|
||||
436
dashboard/src/lib/rhai/format.ts
Normal file
436
dashboard/src/lib/rhai/format.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
// Rhai source formatter.
|
||||
//
|
||||
// Parses the source, walks the AST, emits canonical text. On a parse
|
||||
// failure it returns the first error and leaves the caller responsible
|
||||
// for showing it (the dashboard's UX mirrors the JSON "Format" button:
|
||||
// the doc is untouched and the error is surfaced inline).
|
||||
//
|
||||
// Choices:
|
||||
// * Indent = one tab. The dashboard CSS is tab-based and the editor
|
||||
// keymaps `indentWithTab`, so matching the existing convention.
|
||||
// * Print width = 100 cols. If an inline-printed call's argument list
|
||||
// would push the current line past 100 cols, the args reflow one
|
||||
// per line with a trailing comma.
|
||||
// * Comments preserved verbatim. Each comment lands on its own line at
|
||||
// the indent of the statement it precedes — same-line inline
|
||||
// positioning is intentionally NOT recovered; the goal is "verbatim
|
||||
// text", not "byte-exact placement".
|
||||
// * Block bodies always use multi-line braces. `{}` for empty.
|
||||
// * If parse errors are reported by the parser, the formatter refuses
|
||||
// to emit anything and returns the first error.
|
||||
|
||||
import type {
|
||||
BlockExpr,
|
||||
Comment,
|
||||
Expr,
|
||||
IfExpr,
|
||||
ObjectMapExpr,
|
||||
ParseError,
|
||||
ParseResult,
|
||||
Stmt,
|
||||
SwitchExpr
|
||||
} from './ast';
|
||||
import { parse } from './parser';
|
||||
|
||||
const PRINT_WIDTH = 100;
|
||||
|
||||
export type FormatResult =
|
||||
| { ok: true; text: string }
|
||||
| { ok: false; error: { message: string; offset: number } };
|
||||
|
||||
export function format(source: string): FormatResult {
|
||||
const result = parse(source);
|
||||
if (result.errors.length > 0) {
|
||||
const first = result.errors[0];
|
||||
return { ok: false, error: errorPayload(first) };
|
||||
}
|
||||
const p = new Printer(result);
|
||||
p.printProgram();
|
||||
return { ok: true, text: p.finish() };
|
||||
}
|
||||
|
||||
function errorPayload(e: ParseError): { message: string; offset: number } {
|
||||
return { message: e.message, offset: e.start };
|
||||
}
|
||||
|
||||
class Printer {
|
||||
private buf = '';
|
||||
private indent = 0;
|
||||
private commentPtr = 0;
|
||||
|
||||
constructor(private result: ParseResult) {}
|
||||
|
||||
finish(): string {
|
||||
this.drainCommentsBefore(this.result.source.length + 1);
|
||||
// Strip trailing whitespace from every line; ensure a single
|
||||
// terminating newline.
|
||||
const text = this.buf.replace(/[ \t]+$/gm, '').replace(/\n*$/, '\n');
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- emit
|
||||
|
||||
private emit(s: string): void {
|
||||
this.buf += s;
|
||||
}
|
||||
|
||||
private newline(): void {
|
||||
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||
}
|
||||
|
||||
private blankLine(): void {
|
||||
// Two newlines, but never more than that.
|
||||
if (this.buf.endsWith('\n\n' + '\t'.repeat(this.indent))) return;
|
||||
// Remove any trailing indent we already wrote so the blank line is
|
||||
// truly blank (no stray tabs).
|
||||
this.buf = this.buf.replace(/[ \t]*$/, '');
|
||||
if (!this.buf.endsWith('\n')) this.buf += '\n';
|
||||
this.buf += '\n' + '\t'.repeat(this.indent);
|
||||
}
|
||||
|
||||
private column(): number {
|
||||
const last = this.buf.lastIndexOf('\n');
|
||||
return this.buf.length - (last + 1);
|
||||
}
|
||||
|
||||
// Run `body` against a scratch buffer, return the text it would have
|
||||
// appended. Useful for measuring before deciding whether to reflow.
|
||||
private measure(body: () => void): string {
|
||||
const prev = this.buf;
|
||||
body();
|
||||
const text = this.buf.slice(prev.length);
|
||||
this.buf = prev;
|
||||
return text;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ comments
|
||||
|
||||
private drainCommentsBefore(pos: number): void {
|
||||
const comments = this.result.comments;
|
||||
while (this.commentPtr < comments.length && comments[this.commentPtr].start < pos) {
|
||||
const c = comments[this.commentPtr++];
|
||||
this.emitComment(c);
|
||||
}
|
||||
}
|
||||
|
||||
private emitComment(c: Comment): void {
|
||||
// Emit each comment on its own line at the current indent. For
|
||||
// block comments we still keep the original text (which may span
|
||||
// multiple lines) verbatim.
|
||||
this.emit(c.text);
|
||||
this.newline();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- program
|
||||
|
||||
printProgram(): void {
|
||||
const stmts = this.result.program.stmts;
|
||||
let prevWasFn = false;
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
const stmt = stmts[i];
|
||||
if (i > 0) {
|
||||
if (prevWasFn || stmt.kind === 'FnDecl') this.blankLine();
|
||||
else this.newline();
|
||||
}
|
||||
this.drainCommentsBefore(stmt.start);
|
||||
this.printStmt(stmt);
|
||||
prevWasFn = stmt.kind === 'FnDecl';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- statements
|
||||
|
||||
private printStmt(stmt: Stmt): void {
|
||||
switch (stmt.kind) {
|
||||
case 'Let':
|
||||
case 'Const': {
|
||||
this.emit(stmt.kind === 'Let' ? 'let ' : 'const ');
|
||||
this.emit(stmt.name);
|
||||
if (stmt.init) {
|
||||
this.emit(' = ');
|
||||
this.printExpr(stmt.init);
|
||||
}
|
||||
this.emit(';');
|
||||
return;
|
||||
}
|
||||
case 'FnDecl': {
|
||||
this.emit('fn ');
|
||||
this.emit(stmt.name);
|
||||
this.emit('(');
|
||||
this.emit(stmt.params.map((p) => p.name).join(', '));
|
||||
this.emit(') ');
|
||||
this.printBlock(stmt.body);
|
||||
return;
|
||||
}
|
||||
case 'ExprStmt': {
|
||||
this.printExpr(stmt.expr);
|
||||
// Preserve whether the user terminated with `;`. Block-form
|
||||
// expressions never take one, so suppress regardless.
|
||||
if (stmt.semi && !isBlockForm(stmt.expr)) this.emit(';');
|
||||
return;
|
||||
}
|
||||
case 'Return': {
|
||||
this.emit('return');
|
||||
if (stmt.value) {
|
||||
this.emit(' ');
|
||||
this.printExpr(stmt.value);
|
||||
}
|
||||
this.emit(';');
|
||||
return;
|
||||
}
|
||||
case 'While': {
|
||||
this.emit('while ');
|
||||
this.printExpr(stmt.cond);
|
||||
this.emit(' ');
|
||||
this.printBlock(stmt.body);
|
||||
return;
|
||||
}
|
||||
case 'Loop': {
|
||||
this.emit('loop ');
|
||||
this.printBlock(stmt.body);
|
||||
return;
|
||||
}
|
||||
case 'For': {
|
||||
this.emit('for ');
|
||||
this.emit(stmt.varName);
|
||||
this.emit(' in ');
|
||||
this.printExpr(stmt.iter);
|
||||
this.emit(' ');
|
||||
this.printBlock(stmt.body);
|
||||
return;
|
||||
}
|
||||
case 'Break':
|
||||
this.emit('break;');
|
||||
return;
|
||||
case 'Continue':
|
||||
this.emit('continue;');
|
||||
return;
|
||||
case 'Try': {
|
||||
this.emit('try ');
|
||||
this.printBlock(stmt.body);
|
||||
this.emit(' catch');
|
||||
if (stmt.catchVar) {
|
||||
this.emit(' (');
|
||||
this.emit(stmt.catchVar);
|
||||
this.emit(') ');
|
||||
} else {
|
||||
this.emit(' ');
|
||||
}
|
||||
this.printBlock(stmt.handler);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private printBlock(block: BlockExpr): void {
|
||||
if (block.stmts.length === 0) {
|
||||
this.drainCommentsBefore(block.end);
|
||||
this.emit('{}');
|
||||
return;
|
||||
}
|
||||
this.emit('{');
|
||||
this.indent++;
|
||||
for (let i = 0; i < block.stmts.length; i++) {
|
||||
this.newline();
|
||||
this.drainCommentsBefore(block.stmts[i].start);
|
||||
this.printStmt(block.stmts[i]);
|
||||
}
|
||||
this.drainCommentsBefore(block.end);
|
||||
this.indent--;
|
||||
this.newline();
|
||||
this.emit('}');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- expressions
|
||||
|
||||
private printExpr(expr: Expr): void {
|
||||
switch (expr.kind) {
|
||||
case 'Ident':
|
||||
this.emit(expr.name);
|
||||
return;
|
||||
case 'Number':
|
||||
case 'String':
|
||||
this.emit(expr.raw);
|
||||
return;
|
||||
case 'Bool':
|
||||
this.emit(expr.value ? 'true' : 'false');
|
||||
return;
|
||||
case 'Null':
|
||||
this.emit('null');
|
||||
return;
|
||||
case 'Member': {
|
||||
this.printExpr(expr.object);
|
||||
// `log::info` was parsed as Member(Ident(log), 'info'); restore
|
||||
// the namespace separator for known namespaces. `ctx.request`
|
||||
// always uses `.` because we parsed it that way.
|
||||
const sep = isNamespacePath(expr.object) ? '::' : '.';
|
||||
this.emit(sep);
|
||||
this.emit(expr.property);
|
||||
return;
|
||||
}
|
||||
case 'Index':
|
||||
this.printExpr(expr.object);
|
||||
this.emit('[');
|
||||
this.printExpr(expr.index);
|
||||
this.emit(']');
|
||||
return;
|
||||
case 'Call':
|
||||
this.printExpr(expr.callee);
|
||||
this.printArgList('(', ')', expr.args);
|
||||
return;
|
||||
case 'Unary':
|
||||
this.emit(expr.op);
|
||||
this.printExpr(expr.operand);
|
||||
return;
|
||||
case 'Binary':
|
||||
this.printExpr(expr.left);
|
||||
this.emit(` ${expr.op} `);
|
||||
this.printExpr(expr.right);
|
||||
return;
|
||||
case 'Assign':
|
||||
this.printExpr(expr.target);
|
||||
this.emit(` ${expr.op} `);
|
||||
this.printExpr(expr.value);
|
||||
return;
|
||||
case 'Paren':
|
||||
this.emit('(');
|
||||
this.printExpr(expr.expr);
|
||||
this.emit(')');
|
||||
return;
|
||||
case 'Array':
|
||||
this.printArgList('[', ']', expr.elements);
|
||||
return;
|
||||
case 'ObjectMap':
|
||||
this.printObjectMap(expr);
|
||||
return;
|
||||
case 'FnExpr':
|
||||
this.emit('fn (');
|
||||
this.emit(expr.params.map((p) => p.name).join(', '));
|
||||
this.emit(') ');
|
||||
this.printBlock(expr.body);
|
||||
return;
|
||||
case 'IfExpr':
|
||||
this.printIf(expr);
|
||||
return;
|
||||
case 'SwitchExpr':
|
||||
this.printSwitch(expr);
|
||||
return;
|
||||
case 'BlockExpr':
|
||||
this.printBlock(expr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private printArgList(open: string, close: string, items: Expr[]): void {
|
||||
if (items.length === 0) {
|
||||
this.emit(open);
|
||||
this.emit(close);
|
||||
return;
|
||||
}
|
||||
const inline = this.measure(() => {
|
||||
this.emit(open);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (i > 0) this.emit(', ');
|
||||
this.printExpr(items[i]);
|
||||
}
|
||||
this.emit(close);
|
||||
});
|
||||
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||
this.emit(inline);
|
||||
return;
|
||||
}
|
||||
this.emit(open);
|
||||
this.indent++;
|
||||
for (const item of items) {
|
||||
this.newline();
|
||||
this.printExpr(item);
|
||||
this.emit(',');
|
||||
}
|
||||
this.indent--;
|
||||
this.newline();
|
||||
this.emit(close);
|
||||
}
|
||||
|
||||
private printObjectMap(expr: ObjectMapExpr): void {
|
||||
if (expr.entries.length === 0) {
|
||||
this.emit('#{}');
|
||||
return;
|
||||
}
|
||||
const inline = this.measure(() => {
|
||||
this.emit('#{ ');
|
||||
for (let i = 0; i < expr.entries.length; i++) {
|
||||
const e = expr.entries[i];
|
||||
if (i > 0) this.emit(', ');
|
||||
this.emit(e.key);
|
||||
this.emit(': ');
|
||||
this.printExpr(e.value);
|
||||
}
|
||||
this.emit(' }');
|
||||
});
|
||||
if (!inline.includes('\n') && this.column() + inline.length <= PRINT_WIDTH) {
|
||||
this.emit(inline);
|
||||
return;
|
||||
}
|
||||
this.emit('#{');
|
||||
this.indent++;
|
||||
for (const e of expr.entries) {
|
||||
this.newline();
|
||||
this.emit(e.key);
|
||||
this.emit(': ');
|
||||
this.printExpr(e.value);
|
||||
this.emit(',');
|
||||
}
|
||||
this.indent--;
|
||||
this.newline();
|
||||
this.emit('}');
|
||||
}
|
||||
|
||||
private printIf(expr: IfExpr): void {
|
||||
this.emit('if ');
|
||||
this.printExpr(expr.cond);
|
||||
this.emit(' ');
|
||||
this.printBlock(expr.then);
|
||||
if (expr.else_) {
|
||||
this.emit(' else ');
|
||||
if (expr.else_.kind === 'IfExpr') this.printIf(expr.else_);
|
||||
else this.printBlock(expr.else_);
|
||||
}
|
||||
}
|
||||
|
||||
private printSwitch(expr: SwitchExpr): void {
|
||||
this.emit('switch ');
|
||||
this.printExpr(expr.subject);
|
||||
this.emit(' {');
|
||||
this.indent++;
|
||||
for (const arm of expr.arms) {
|
||||
this.newline();
|
||||
if (arm.pattern === null) this.emit('_');
|
||||
else this.printExpr(arm.pattern);
|
||||
if (arm.guard) {
|
||||
this.emit(' if ');
|
||||
this.printExpr(arm.guard);
|
||||
}
|
||||
this.emit(' => ');
|
||||
this.printExpr(arm.value);
|
||||
this.emit(',');
|
||||
}
|
||||
this.indent--;
|
||||
this.newline();
|
||||
this.emit('}');
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockForm(expr: Expr): boolean {
|
||||
return expr.kind === 'IfExpr' || expr.kind === 'SwitchExpr' || expr.kind === 'BlockExpr' || expr.kind === 'FnExpr';
|
||||
}
|
||||
|
||||
// Namespace path detection — used by `Member` printing to decide between
|
||||
// `.` and `::`. Currently the only well-known namespace in scripts is
|
||||
// `log`, but we generalize to any bare identifier whose name happens to
|
||||
// be the namespace token. False positives are harmless (we'd render
|
||||
// `something::field` for a local named `log`); the parser-side fix would
|
||||
// be a dedicated `Path` node — not worth it for one keyword.
|
||||
function isNamespacePath(expr: Expr): boolean {
|
||||
if (expr.kind === 'Ident') return expr.name === 'log';
|
||||
return false;
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
export { parse } from './parser';
|
||||
export { tokenize, KEYWORDS } from './lexer';
|
||||
export { buildSymbolTable, renderFnSignature } from './symbols';
|
||||
export { format } from './format';
|
||||
export type { FormatResult } from './format';
|
||||
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
||||
export type {
|
||||
BlockExpr,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
|
||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||
import { format as formatRhai } from '$lib/rhai';
|
||||
|
||||
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||
/// input doesn't parse. The error state is shown next to the button
|
||||
@@ -67,6 +68,17 @@
|
||||
let editableSource = $state('');
|
||||
let savingSource = $state(false);
|
||||
let saveSourceError = $state<string | null>(null);
|
||||
let rhaiFormatError = $state<string | null>(null);
|
||||
|
||||
function formatRhaiSource() {
|
||||
const r = formatRhai(editableSource);
|
||||
if (r.ok) {
|
||||
editableSource = r.text;
|
||||
rhaiFormatError = null;
|
||||
} else {
|
||||
rhaiFormatError = `Parse error at offset ${r.error.offset}: ${r.error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
if (!script) return;
|
||||
@@ -384,8 +396,16 @@
|
||||
{#if tab === 'edit'}
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Source</h2>
|
||||
<header class="editor-header">
|
||||
<h2>Source</h2>
|
||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||
Format
|
||||
</button>
|
||||
</header>
|
||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
||||
{#if rhaiFormatError}
|
||||
<div class="error inline">{rhaiFormatError}</div>
|
||||
{/if}
|
||||
{#if saveSourceError}
|
||||
<div class="error inline">{saveSourceError}</div>
|
||||
{/if}
|
||||
@@ -794,6 +814,14 @@
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.editor-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
button.link {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
|
||||
Reference in New Issue
Block a user