release: v0.14.0

Fourteen commits since v0.13.0. See CHANGELOG.md for the full list.

Two additions: `npm run doctor`, which runs the config doctor from a terminal — including inside the shipped image — and exits with the worst finding, so it can gate a deployment script; and a Playwright spec that loads all fourteen admin routes against a production build.

One change, invisible on screen: the admin panel got a form-field layer (#533).

Five fixes: grid.gap on both axes (#513), the dashboard status badge (#539), the journal editor's unsaved-changes guard (#538), essay paragraph markup (#537), and aria-pressed on the panel's switches and pickers.
This commit is contained in:
Ralksta
2026-08-30 15:11:25 +02:00
committed by GitHub
parent a813433891
commit e99d3f2f1b
59 changed files with 8652 additions and 6676 deletions

View File

@@ -0,0 +1,79 @@
'use client';
import type { ReactNode } from 'react';
type GridOverride = { columns?: number; gap?: number };
const NUMBER_INPUT_STYLE = {
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
} as const;
/**
* The override editor for one grid: a column count and a gap, both optional,
* both falling back to a wider setting when left empty.
*
* Giving the album covers their own grid (#523) was implemented by copying this
* block — fifty-eight lines whose entire diff was the bounds it clamps to and
* the key it writes (#533). Two editors that have to behave identically had two
* implementations, which is how they drift apart.
*
* A value outside the bounds is discarded rather than clamped, which is what
* the copies did: the field then reads as empty and the setting falls back,
* instead of silently saving a number nobody typed.
*/
export default function GridOverrideFields({
label,
hint,
columnsMin,
columnsMax,
gapMax,
value,
onChange,
}: {
label: string;
hint: ReactNode;
columnsMin: number;
columnsMax: number;
gapMax: number;
value: GridOverride | undefined;
/** Receives the whole next override; the caller normalises and stores it. */
onChange: (next: GridOverride) => void;
}) {
const patch = (key: 'columns' | 'gap', raw: string, min: number, max: number) => {
const n = raw === '' ? undefined : Number(raw);
onChange({
...(value || {}),
[key]: n != null && n >= min && n <= max ? n : undefined,
});
};
return (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>{label}</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={columnsMin}
max={columnsMax}
value={value?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => patch('columns', e.target.value, columnsMin, columnsMax)}
style={NUMBER_INPUT_STYLE}
/>
<input
type="number"
min={0}
max={gapMax}
value={value?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => patch('gap', e.target.value, 0, gapMax)}
style={NUMBER_INPUT_STYLE}
/>
</div>
<p className="admin-field-hint">{hint}</p>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
export type CardOption = { value: string; label: string; desc: string };
/**
* Builds the option list for a picker from its value list and a label map, so
* the values a setting accepts and the words shown for them stay one thing.
*/
export function toOptions(
values: readonly string[],
info: Record<string, { label: string; desc: string }>,
): CardOption[] {
return values.map((value) => ({
value,
label: info[value]?.label ?? value,
desc: info[value]?.desc ?? '',
}));
}
/**
* A labelled grid of picker cards — theme preset, layout, frame, hero style,
* aspect ratio.
*
* All five were written out separately: each had its own `.map()`, its own
* `preset-card` button, its own `active` handling, and its label text buried in
* an object literal a thousand lines from the value list it described (#533).
* The differences between them are the preview drawn on the card and, for the
* theme preset, a few custom properties — so those are the props, and the rest
* is stated once.
*
* The class names are unchanged, so `admin.css` styles these exactly as it
* styled the copies.
*/
export default function OptionGrid({
label,
options,
value,
onSelect,
cardClassName,
renderPreview,
renderSpecs,
cardStyle,
}: {
label: string;
options: CardOption[];
/** The resolved value — the caller applies its own default before passing it. */
value: string;
onSelect: (value: string) => void;
/** Second class on the card, e.g. `frame-card`; `preset-card` is always set. */
cardClassName: string;
renderPreview?: (option: CardOption) => ReactNode;
/** Extra line under the description, used by the theme preset for its specs. */
renderSpecs?: (option: CardOption) => ReactNode;
cardStyle?: (option: CardOption) => CSSProperties | undefined;
}) {
return (
<div className="admin-field">
<label>{label}</label>
<div className="preset-card-grid">
{options.map((option) => {
const isActive = value === option.value;
return (
<button
key={option.value}
type="button"
className={`preset-card ${cardClassName} ${isActive ? 'active' : ''}`}
onClick={() => onSelect(option.value)}
style={cardStyle?.(option)}
aria-pressed={isActive}
>
{renderPreview?.(option)}
<div className="preset-card-info">
<span className="preset-card-name">{option.label}</span>
<span className="preset-card-desc">{option.desc}</span>
{renderSpecs?.(option)}
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import type { ReactNode } from 'react';
/**
* A switch rendered as a card — the panel's top-level on/off control.
*
* The markup used to be written out at every call site: fourteen copies of the
* same eighteen lines, differing only in the label and which setting they read
* (#533). Changing how a switch looked meant editing it fourteen times, and the
* copies had already drifted apart in their line wrapping. The class names are
* unchanged, so `admin.css` styles this exactly as it styled the copies.
*
* `SettingRow` is the lighter sibling for switches *inside* a group panel; this
* is the card that sits at the top level of a section.
*/
export default function ToggleCard({
icon,
title,
description,
checked,
onToggle,
}: {
icon: ReactNode;
title: ReactNode;
description: ReactNode;
checked: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
className={`admin-toggle-card ${checked ? 'active' : ''}`}
onClick={onToggle}
aria-pressed={checked}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
{icon} {title}
</span>
<span className="toggle-card-desc">{description}</span>
</div>
<div className={`switch-toggle ${checked ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
);
}