Adopt shared WebUI structural primitives

This commit is contained in:
2026-08-18 13:17:32 +02:00
parent cf4205f780
commit 3856765520
2 changed files with 59 additions and 102 deletions
+58 -45
View File
@@ -15,15 +15,23 @@ import { DialogSection, ToolbarGroup, ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
ConfirmDialog, ConfirmDialog,
ContentSection,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FilterBar,
FormField, FormField,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
SegmentedControl, SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
WorkspaceFrame,
WorkspaceLayout,
formatDateTime, formatDateTime,
hasScope, hasScope,
useUnsavedChanges, useUnsavedChanges,
@@ -293,20 +301,27 @@ export default function TemplatesPage({ settings, auth }: Props) {
}; };
return ( return (
<main className="templates-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="templates-page" label="Template workspace">
<div className="templates-shell"> <WorkspaceLayout
<aside className="templates-sidebar"> variant="split"
<ActionToolbar className="templates-sidebar-toolbar"> primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Template library"
contentLabel="Template workspace"
contentClassName="templates-workspace"
primary={<>
<ActionToolbar surface="panel-header">
<strong>Template library</strong> <strong>Template library</strong>
<IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} /> <IconButton label="Add template" icon={<Plus size={17} />} variant="primary" disabled={!canWrite} disabledReason={!canWrite ? TEMPLATES_I18N.writeReason : undefined} onClick={() => requestDiscard(() => setCreateOpen(true))} />
</ActionToolbar> </ActionToolbar>
<div className="templates-search"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></div> <FilterBar surface="panel"><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search templates" /></FilterBar>
<div className="templates-list"> <SelectionList variant="navigation" label="Templates">
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<button <SelectionListItem
key={item.id} key={item.id}
type="button" selected={item.id === selectedId}
className={item.id === selectedId ? "is-selected" : ""}
onClick={() => { onClick={() => {
if (item.id === selectedId) return; if (item.id === selectedId) return;
requestDiscard(() => { requestDiscard(() => {
@@ -315,16 +330,15 @@ export default function TemplatesPage({ settings, auth }: Props) {
}); });
}} }}
> >
<span><strong>{item.name}</strong><small>{typeLabel(item.template_type)} · revision {item.current_revision}</small></span> <SelectionListItemContent title={item.name} description={`${typeLabel(item.template_type)} · revision ${item.current_revision}`} />
<StatusBadge status={item.status} label={item.status} /> <StatusBadge status={item.status} label={item.status} />
</button> </SelectionListItem>
))} ))}
{!visibleItems.length && <p className="templates-empty">No matching templates.</p>} {!visibleItems.length && <StatePanel size="compact" description="No matching templates." />}
</div> </SelectionList>
</aside> </>}
>
<section className="templates-workspace"> <ActionToolbar surface="panel-header" className="templates-workspace-toolbar">
<ActionToolbar className="templates-workspace-toolbar">
<span className="templates-current-title"> <span className="templates-current-title">
<strong>{selected?.name ?? "Select a template"}</strong> <strong>{selected?.name ?? "Select a template"}</strong>
<small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small> <small>{selected ? `${typeLabel(selected.template_type)} · ${selected.revision.locale}` : ""}</small>
@@ -363,7 +377,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
<LoadingFrame loading={loading} label="Loading templates"> <LoadingFrame loading={loading} label="Loading templates">
<div className="templates-content"> <div className="templates-content">
{!selected ? <p className="templates-empty">Create or select a reusable template.</p> : view === "definition" ? <> {!selected ? <StatePanel size="fill" title="Templates" description="Create or select a reusable template." /> : view === "definition" ? <>
<DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} /> <DefinitionEditor draft={draft} disabled={readOnly || busy} auth={auth} onChange={setDraft} />
<RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} /> <RevisionHistory revisions={revisions} currentRevisionId={selected.current_revision_id} publishedRevisionId={selected.published_revision_id ?? null} />
</> : <> </> : <>
@@ -388,8 +402,7 @@ export default function TemplatesPage({ settings, auth }: Props) {
</>} </>}
</div> </div>
</LoadingFrame> </LoadingFrame>
</section> </WorkspaceLayout>
</div>
<Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}> <Dialog open={createOpen} title="Add template" onClose={closeCreate} closeDisabled={busy} footer={<><Button onClick={closeCreate} disabled={busy} disabledReason={busy ? TEMPLATES_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={!createName.trim() || busy} disabledReason={busy ? TEMPLATES_I18N.busy : !createName.trim() ? TEMPLATES_I18N.incomplete : undefined} onClick={() => void create()}>Create</Button></>}>
<DialogSection className="templates-dialog-form"> <DialogSection className="templates-dialog-form">
@@ -400,13 +413,13 @@ export default function TemplatesPage({ settings, auth }: Props) {
<ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} /> <ConfirmDialog open={publishOpen} title="i18n:govoplan-templates.publish_title" message="i18n:govoplan-templates.publish_message" confirmLabel="Publish" busy={busy} onCancel={() => setPublishOpen(false)} onConfirm={() => void publish()} />
<ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} /> <ConfirmDialog open={finalRenderOpen} title="i18n:govoplan-templates.render_title" message="i18n:govoplan-templates.render_message" confirmLabel="Render final output" busy={busy} onCancel={() => setFinalRenderOpen(false)} onConfirm={() => void runRender(true)} />
<ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} /> <ConfirmDialog open={deleteOpen} title="Delete template?" message="Existing render evidence remains until module retention removes it. Consumers can no longer select this template." confirmLabel="Delete" tone="danger" busy={busy} onCancel={() => setDeleteOpen(false)} onConfirm={() => void remove()} />
</main> </WorkspaceFrame>
); );
} }
function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) { function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }: { revisions: TemplateRevision[]; currentRevisionId: string; publishedRevisionId: string | null }) {
return <section className="templates-section templates-history"> return <ContentSection className="templates-history">
<div className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Revision history</strong><small>{revisions.length} immutable revision(s)</small></ActionToolbar>
<div className="templates-history-list"> <div className="templates-history-list">
{revisions.map((revision) => <div key={revision.id}> {revisions.map((revision) => <div key={revision.id}>
<span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span> <span><strong>Revision {revision.revision}</strong><small>{formatDateTime(revision.created_at)} · {shortHash(revision.definition_hash)}</small></span>
@@ -415,22 +428,22 @@ function RevisionHistory({ revisions, currentRevisionId, publishedRevisionId }:
{revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />} {revision.id === publishedRevisionId && <StatusBadge status="active" label="Published" />}
</span> </span>
</div>)} </div>)}
{!revisions.length && <p className="templates-inline-empty">No revision evidence is available.</p>} {!revisions.length && <StatePanel size="inline" description="No revision evidence is available." />}
</div> </div>
</section>; </ContentSection>;
} }
function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) { function RenderHistory({ renders, settings, onError }: { renders: TemplateRender[]; settings: ApiSettings; onError: (message: string) => void }) {
return <section className="templates-section templates-history"> return <ContentSection className="templates-history">
<div className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Output history</strong><small>{renders.length} recent render(s)</small></ActionToolbar>
<div className="templates-history-list"> <div className="templates-history-list">
{renders.map((item) => <div key={item.render_id}> {renders.map((item) => <div key={item.render_id}>
<span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span> <span><strong>{item.filename}</strong><small>{item.generated_at ? formatDateTime(item.generated_at) : "Generated"} · {item.item_count} item(s) · {shortHash(item.output_sha256)}</small></span>
<Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button> <Button disabled={!item.artifact?.download_path} onClick={() => void downloadTemplateRender(settings, item).catch((caught) => onError(errorMessage(caught)))}><Download size={15} /> Download</Button>
</div>)} </div>)}
{!renders.length && <p className="templates-inline-empty">No output has been rendered for this template.</p>} {!renders.length && <StatePanel size="inline" description="No output has been rendered for this template." />}
</div> </div>
</section>; </ContentSection>;
} }
function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) { function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: TemplatePayload; disabled: boolean; auth: AuthInfo; onChange: (draft: TemplatePayload) => void }) {
@@ -453,8 +466,8 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField> <FormField label="Description"><input disabled={disabled} value={draft.description ?? ""} onChange={(event) => update("description", event.target.value || null)} /></FormField>
</div> </div>
<section className="templates-section"> <ContentSection>
<div className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Required data contract</strong><Button disabled={disabled} onClick={() => update("required_fields", [...draft.required_fields, emptyField()])}><Plus size={15} /> Add field</Button></ActionToolbar>
<div className="templates-fields-table"> <div className="templates-fields-table">
{draft.required_fields.map((field, index) => ( {draft.required_fields.map((field, index) => (
<div className="templates-field-row" key={`${index}:${field.path}`}> <div className="templates-field-row" key={`${index}:${field.path}`}>
@@ -465,12 +478,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} /> <IconButton label="Remove field" icon={<X size={16} />} variant="ghost" disabled={disabled} onClick={() => update("required_fields", draft.required_fields.filter((_, fieldIndex) => fieldIndex !== index))} />
</div> </div>
))} ))}
{!draft.required_fields.length && <p className="templates-inline-empty">No required fields. Tokens still resolve from supplied parameters and items.</p>} {!draft.required_fields.length && <StatePanel size="inline" description="No required fields. Tokens still resolve from supplied parameters and items." />}
</div> </div>
</section> </ContentSection>
<section className="templates-section"> <ContentSection>
<div className="templates-section-heading"><strong>Page and media</strong></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Page and media</strong></ActionToolbar>
<div className="templates-layout-fields"> <div className="templates-layout-fields">
<FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField> <FormField label="Page size"><select disabled={disabled} value={String(layout.page_size ?? pageSizeForType(draft.template_type))} onChange={(event) => update("layout", { ...layout, page_size: event.target.value })}>{["A3", "A4", "A5", "Letter", "Legal", "DL"].map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField> <FormField label="Margin (mm)"><input type="number" min="0" max="60" disabled={disabled} value={Number(layout.margin_mm ?? 15)} onChange={(event) => update("layout", { ...layout, margin_mm: Number(event.target.value) })} /></FormField>
@@ -480,12 +493,12 @@ function DefinitionEditor({ draft, disabled, auth, onChange }: { draft: Template
<FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField> <FormField label="Gap (mm)"><input type="number" min="0" max="20" disabled={disabled} value={Number(layout.gap_mm ?? 2)} onChange={(event) => update("layout", { ...layout, gap_mm: Number(event.target.value) })} /></FormField>
</>} </>}
</div> </div>
</section> </ContentSection>
<section className="templates-section templates-body-section"> <ContentSection className="templates-body-section">
<div className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Template body</strong><small>Use tokens such as {"{{name}}"} or {"{{recipient.address}}"}.</small></ActionToolbar>
<WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} /> <WysiwygEditor disabled={disabled} value={draft.content_html ?? ""} onChange={(value) => update("content_html", value || null)} minHeight={300} />
</section> </ContentSection>
</div> </div>
); );
} }
@@ -509,8 +522,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
}) { }) {
return ( return (
<div className="templates-preview"> <div className="templates-preview">
<section className="templates-section"> <ContentSection>
<div className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Validated sample input</strong><small>Preview and final output use the same pinned revision and canonical input.</small></ActionToolbar>
<div className="templates-preview-controls"> <div className="templates-preview-controls">
<FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField> <FormField label="Usage" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><select value={usage} onChange={(event) => onUsage(event.target.value)}>{item.revision.usages.map((value) => <option key={value} value={value}>{value}</option>)}</select></FormField>
<FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField> <FormField label="Output" documentation={TEMPLATE_OUTPUT_DOCUMENTATION}><SegmentedControl value={outputFormat} onChange={onOutputFormat} options={[{ id: "html", label: "Printable HTML" }, { id: "text", label: "Plain text" }]} ariaLabel="Output format" /></FormField>
@@ -521,7 +534,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button> <Button disabled={disabled} disabledReason={disabled ? disabledReason : undefined} onClick={() => onRender(false)}><Eye size={16} /> Validate and preview</Button>
<Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button> <Button variant="primary" disabled={disabled || !item.revision.published_at} disabledReason={disabled ? disabledReason : !item.revision.published_at ? "Publish this revision before producing final output." : undefined} onClick={() => onRender(true)}><Send size={16} /> Render final output</Button>
</div> </div>
</section> </ContentSection>
{compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}> {compatibility && <DismissibleAlert tone={compatibility.compatible ? "success" : "danger"}>
{compatibility.compatible {compatibility.compatible
@@ -529,8 +542,8 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
: compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")} : compatibility.diagnostics.map((item) => String(item.message ?? item.code ?? "Incompatible input")).join(" ")}
</DismissibleAlert>} </DismissibleAlert>}
{render && <section className="templates-section templates-render-result"> {render && <ContentSection className="templates-render-result">
<div className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></div> <ActionToolbar surface="section-header" className="templates-section-heading"><strong>Render evidence</strong><Button disabled={!render.artifact?.download_path} onClick={onDownload}><Download size={16} /> Download</Button></ActionToolbar>
<dl> <dl>
<div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div> <div><dt>Revision</dt><dd>{render.revision} · {shortHash(render.template_hash)}</dd></div>
<div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div> <div><dt>Input</dt><dd>{shortHash(render.input_hash)}</dd></div>
@@ -540,7 +553,7 @@ function PreviewPanel({ item, sampleText, usage, outputFormat, persistToFiles, c
<div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div> <div><dt>Generated</dt><dd>{render.generated_at ? formatDateTime(render.generated_at) : "Now"}</dd></div>
</dl> </dl>
<p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p> <p>{render.artifact?.kind === "managed_file" ? "Managed by Files" : "Bounded Templates download"} · {render.output_size_bytes.toLocaleString()} bytes</p>
</section>} </ContentSection>}
</div> </div>
); );
} }
+1 -57
View File
@@ -1,71 +1,19 @@
.templates-page { .templates-workspace-toolbar { min-width: 0; }
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.templates-page *, .templates-page *::before, .templates-page *::after { box-sizing: border-box; }
.templates-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.templates-sidebar, .templates-workspace { min-width: 0; min-height: 0; }
.templates-sidebar { display: flex; flex-direction: column; overflow: hidden; border-right: var(--border-line); background: var(--panel-soft); }
.templates-workspace { display: flex; flex-direction: column; overflow: hidden; background: var(--bg); }
.templates-sidebar-toolbar, .templates-workspace-toolbar, .templates-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.templates-sidebar-toolbar { min-height: 52px; padding: 8px 10px 8px 14px; }
.templates-workspace-toolbar { min-height: 58px; padding: 8px 10px 8px 14px; }
.templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; } .templates-toolbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .templates-toolbar-actions .btn { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.templates-current-title { min-width: 0; flex: 1 1 auto; } .templates-current-title { min-width: 0; flex: 1 1 auto; }
.templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .templates-current-title strong, .templates-current-title small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; } .templates-current-title small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-search { padding: 9px; border-bottom: var(--border-line); background: var(--panel); }
.templates-search input { width: 100%; min-height: 34px; padding: 7px 9px; }
.templates-list { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 6px; }
.templates-list > button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 56px; padding: 8px 9px; border: 0; border-radius: var(--radius-sm); color: var(--text); background: transparent; cursor: pointer; text-align: left; }
.templates-list > button:hover, .templates-list > button:focus-visible { background: var(--primary-soft); outline: 0; }
.templates-list > button.is-selected { background: var(--primary-soft-strong); box-shadow: inset 3px 0 0 var(--accent); }
.templates-list strong, .templates-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.templates-list small { margin-top: 3px; color: var(--muted); font-size: 11px; }
.templates-alerts { flex: 0 0 auto; padding: 0 12px; } .templates-alerts { flex: 0 0 auto; padding: 0 12px; }
.templates-alerts:empty { display: none; } .templates-alerts:empty { display: none; }
.templates-alerts .alert { margin: 10px 0 0; } .templates-alerts .alert { margin: 10px 0 0; }
.templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; } .templates-workspace > .loading-frame { flex: 1 1 auto; min-height: 0; }
.templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; } .templates-content { height: 100%; min-width: 0; min-height: 0; overflow: auto; padding: 14px; }
.templates-empty, .templates-inline-empty { display: grid; place-items: center; min-height: 90px; padding: 16px; color: var(--muted); font-size: 13px; text-align: center; }
.templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; } .templates-definition-fields { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(130px, .7fr)); gap: 12px; margin-bottom: 14px; }
.templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; } .templates-definition-fields .form-field:nth-child(5) { grid-column: span 2; }
.templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; } .templates-definition-fields input, .templates-definition-fields select, .templates-dialog-form input, .templates-dialog-form select, .templates-layout-fields input, .templates-layout-fields select, .templates-preview-controls select { width: 100%; }
.templates-section { min-width: 0; margin-bottom: 14px; border: var(--border-line); background: var(--panel); }
.templates-section-heading { min-height: 44px; padding: 7px 10px; }
.templates-section-heading small { color: var(--muted); font-weight: 400; } .templates-section-heading small { color: var(--muted); font-weight: 400; }
.templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; } .templates-section-heading .btn { display: inline-flex; align-items: center; gap: 6px; }
.templates-fields-table { overflow: auto; padding: 8px; } .templates-fields-table { overflow: auto; padding: 8px; }
@@ -94,7 +42,6 @@
.templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); } .templates-dialog-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(180px, .7fr); gap: 12px; min-width: min(560px, 80vw); }
@media (max-width: 980px) { @media (max-width: 980px) {
.templates-shell { grid-template-columns: minmax(210px, 250px) minmax(0, 1fr); }
.templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } .templates-definition-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.templates-definition-fields .form-field:nth-child(5) { grid-column: auto; } .templates-definition-fields .form-field:nth-child(5) { grid-column: auto; }
.templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } .templates-layout-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -104,9 +51,6 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; } .templates-page { height: auto; min-height: calc(100vh - 100px); overflow: visible; }
.templates-shell { display: flex; flex-direction: column; height: auto; overflow: visible; }
.templates-sidebar { max-height: 280px; border-right: 0; border-bottom: var(--border-line); }
.templates-workspace { overflow: visible; }
.templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; } .templates-workspace-toolbar { align-items: flex-start; flex-wrap: wrap; }
.templates-toolbar-actions { flex-wrap: wrap; } .templates-toolbar-actions { flex-wrap: wrap; }
.templates-content { height: auto; overflow: visible; } .templates-content { height: auto; overflow: visible; }