feat(web): visual redesign pass across collection modals (#138)

Closes TASK-598 in PLAN-593. Applies the design doc recorded on the
task before implementation.

The work:

1. Field card alignment (resolves the T2 regression)
   Key row moved out of the header flex into a full-width block
   below. Header is now baseline-aligned at a consistent height:
   drag handle, label input, type-select, remove button all sit on
   one row regardless of whether a key row is present. Label-and-
   key feel related without being cramped.

2. Emoji picker parity
   Both modals' General tabs now render EmojiPickerButton (size
   md) next to the name input, matching the Quick Actions pattern.
   The inline .icon-btn + <EmojiPicker> dance, its showEmojiPicker
   state, and all supporting CSS are deleted.

3. Danger zone
   Archive moved out of the footer into a dedicated "Danger zone"
   section at the bottom of the General tab. Red-tinted background,
   red section header, red destructive button that fills on hover.
   Confirmation flow lives inside the section (not crammed into
   the footer). Footer is now Cancel + Save Changes only.

4. Empty states
   Fields empty state gets icon + title + description (not a bare
   string). Quick Actions empty states explain what item / collection
   actions are for, inside a dashed-bordered suggestion block.

5. Template picker
   Blank card shares the same structure as other templates — a
   muted circular + icon wrapper instead of a dashed outline. All
   cards have a consistent min-height so Blank doesn't look stubby.
   Added :focus-visible outline for keyboard users.

6. Typography rhythm
   Section labels normalized to the app convention: 0.75em, 600,
   uppercase, 0.05em tracking, --text-muted. Applied to
   .fields-label, .form-label, .actions-section-title.

7. Responsive
   Both modals: 16px overlay padding, scrollable content, full-
   width under 640px. Edit modal tab bar scrolls horizontally with
   a right-edge fade mask under 640px; settings grid collapses to
   one column; footer buttons fill width.

8. Motion
   Modal fade + subtle scale-in (160ms ease-out). Respects
   prefers-reduced-motion. Existing tab/chevron transitions kept.

Out of scope (per plan): new features (T6), backend changes,
schema shape changes.
This commit is contained in:
xarmian
2026-04-17 17:27:59 -04:00
committed by GitHub
parent 26d124f19d
commit 6d5fa969e2
3 changed files with 410 additions and 245 deletions
@@ -2,7 +2,7 @@
import { api } from '$lib/api/client';
import type { CollectionCreate, FieldDef, CollectionSettings } from '$lib/types';
import { COLLECTION_TEMPLATES, type CollectionTemplate } from './collection-templates';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import EmojiPickerButton from '$lib/components/common/EmojiPickerButton.svelte';
import FieldEditor, { type CollectionOption } from './FieldEditor.svelte';
import {
blankField,
@@ -30,7 +30,6 @@
let name = $state('');
let selectedIcon = $state('');
let description = $state('');
let showEmojiPicker = $state(false);
let fields = $state<EditableField[]>([]);
let selectedSettings = $state<CollectionSettings | null>(null);
let creating = $state(false);
@@ -55,7 +54,6 @@
description = '';
fields = [];
selectedSettings = null;
showEmojiPicker = false;
error = '';
void loadCollectionOptions();
}
@@ -91,7 +89,6 @@
description = '';
fields = [];
selectedSettings = null;
showEmojiPicker = false;
error = '';
}
@@ -294,7 +291,11 @@
type="button"
onclick={() => selectTemplate(null)}
>
<span class="template-icon">+</span>
<span class="template-icon-wrap" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
<span class="template-name">Blank</span>
<span class="template-desc">Start from scratch</span>
</button>
@@ -304,7 +305,7 @@
type="button"
onclick={() => selectTemplate(template)}
>
<span class="template-icon">{template.icon}</span>
<span class="template-icon" aria-hidden="true">{template.icon}</span>
<span class="template-name">{template.name}</span>
<span class="template-desc">{template.description}</span>
</button>
@@ -318,17 +319,7 @@
{/if}
<div class="name-row">
<button
class="icon-btn"
type="button"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
{#if selectedIcon}
<span class="icon-preview">{selectedIcon}</span>
{:else}
<span class="icon-placeholder">+</span>
{/if}
</button>
<EmojiPickerButton bind:value={selectedIcon} placeholder="+" size="md" />
<input
class="name-input"
type="text"
@@ -337,18 +328,6 @@
/>
</div>
{#if showEmojiPicker}
<div class="emoji-picker-container">
<EmojiPicker
selected={selectedIcon}
onselect={(emoji) => {
selectedIcon = emoji;
showEmojiPicker = false;
}}
/>
</div>
{/if}
<input
class="desc-input"
type="text"
@@ -409,7 +388,8 @@
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
padding: 10vh var(--space-4) var(--space-4);
animation: overlay-in 140ms ease-out;
}
.modal {
@@ -422,6 +402,22 @@
overflow: hidden;
max-height: 80vh;
overflow-y: auto;
animation: modal-in 160ms ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Honor prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.overlay, .modal { animation: none; }
}
/* -- Header ------------------------------------------------------------ */
@@ -513,12 +509,19 @@
gap: var(--space-3);
}
/*
* Unified card: both emoji-icon templates and the Blank card share the
* same structure. Blank gets a muted circular icon wrapper instead of
* a dashed outline so it feels like a first-class option rather than
* a fallback.
*/
.template-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
padding: var(--space-4);
padding: var(--space-3) var(--space-4);
min-height: 108px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -532,32 +535,49 @@
background: color-mix(in srgb, var(--accent-blue) 5%, var(--bg-tertiary));
}
.template-card--blank {
border-style: dashed;
border-color: var(--border);
}
.template-card--blank:hover {
border-style: dashed;
border-color: var(--accent-blue);
.template-card:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: 2px;
}
.template-icon {
font-size: 1.5em;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 1.4em;
line-height: 1;
margin-bottom: var(--space-1);
}
.template-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-muted) 18%, transparent);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.template-card--blank:hover .template-icon-wrap {
background: color-mix(in srgb, var(--accent-blue) 20%, transparent);
color: var(--accent-blue);
}
.template-name {
font-size: 0.9em;
font-size: 0.92em;
font-weight: 600;
color: var(--text-primary);
margin-top: var(--space-1);
}
.template-desc {
font-size: 0.78em;
font-size: 0.8em;
color: var(--text-muted);
line-height: 1.35;
line-height: 1.4;
}
/* -- Step 2: Icon + Name row ------------------------------------------- */
@@ -568,35 +588,6 @@
gap: var(--space-3);
}
.icon-btn {
width: 48px;
height: 48px;
min-width: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
font-size: 1.5em;
line-height: 1;
padding: 0;
}
.icon-btn:hover {
border-color: var(--border);
}
.icon-preview {
font-size: 1em;
}
.icon-placeholder {
color: var(--text-muted);
font-size: 0.8em;
}
.name-input {
flex: 1;
padding: var(--space-2) var(--space-3);
@@ -616,12 +607,6 @@
outline: none;
}
/* -- Emoji picker ------------------------------------------------------ */
.emoji-picker-container {
margin-bottom: var(--space-3);
}
/* -- Description ------------------------------------------------------- */
.desc-input {
@@ -656,11 +641,11 @@
}
.fields-label {
font-size: 0.85em;
font-size: 0.75em;
font-weight: 600;
color: var(--text-secondary);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
}
.fields-list {
@@ -733,4 +718,31 @@
opacity: 0.5;
cursor: not-allowed;
}
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay {
padding: var(--space-3);
align-items: stretch;
}
.modal {
max-width: 100%;
max-height: calc(100vh - var(--space-6));
}
.template-grid {
grid-template-columns: 1fr;
}
.modal-footer {
flex-wrap: wrap;
gap: var(--space-2);
}
.btn-cancel, .btn-create {
flex: 1 1 auto;
}
}
</style>
@@ -2,7 +2,6 @@
import { api } from '$lib/api/client';
import type { Collection, CollectionUpdate, CollectionSettings, FieldDef, FieldMigration, QuickAction } from '$lib/types';
import { parseSchema, parseSettings } from '$lib/types';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import EmojiPickerButton from '$lib/components/common/EmojiPickerButton.svelte';
import FieldEditor, { type CollectionOption } from './FieldEditor.svelte';
import {
@@ -55,7 +54,6 @@
let name = $state('');
let selectedIcon = $state('');
let description = $state('');
let showEmojiPicker = $state(false);
let saving = $state(false);
let error = $state('');
@@ -182,7 +180,6 @@
originalDefault: f.default
}));
newFields = [];
showEmojiPicker = false;
error = '';
activeTab = 'general';
confirmArchive = false;
@@ -541,17 +538,7 @@
<!-- ── General Tab ──────────────────────────────────────── -->
<div class="tab-content">
<div class="name-row">
<button
class="icon-btn"
type="button"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
{#if selectedIcon}
<span class="icon-preview">{selectedIcon}</span>
{:else}
<span class="icon-placeholder">+</span>
{/if}
</button>
<EmojiPickerButton bind:value={selectedIcon} placeholder="+" size="md" />
<input
class="name-input"
type="text"
@@ -560,18 +547,6 @@
/>
</div>
{#if showEmojiPicker}
<div class="emoji-picker-container">
<EmojiPicker
selected={selectedIcon}
onselect={(emoji) => {
selectedIcon = emoji;
showEmojiPicker = false;
}}
/>
</div>
{/if}
<div class="form-group">
<label class="form-label" for="edit-desc">Description</label>
<input
@@ -589,12 +564,65 @@
<div class="prefix-display">{collection.prefix}</div>
</div>
{/if}
{#if !collection.is_default}
<section class="danger-zone" aria-labelledby="danger-zone-heading">
<header class="danger-zone-header">
<h3 id="danger-zone-heading" class="danger-zone-title">Danger zone</h3>
<p class="danger-zone-hint">
Archiving removes this collection and all its items from the workspace.
This can't be undone from the UI.
</p>
</header>
{#if confirmArchive}
<div class="danger-zone-confirm" role="alertdialog" aria-labelledby="archive-confirm-msg">
<p id="archive-confirm-msg" class="danger-zone-confirm-msg">
Archive <strong>"{collection.name}"</strong> and all its items?
</p>
<div class="danger-zone-confirm-actions">
<button
class="btn-archive-confirm"
type="button"
onclick={handleArchive}
disabled={archiving}
>
{archiving ? 'Archiving…' : 'Yes, archive'}
</button>
<button
class="btn-archive-cancel"
type="button"
onclick={() => (confirmArchive = false)}
disabled={archiving}
>
Cancel
</button>
</div>
</div>
{:else}
<button class="btn-archive" type="button" onclick={handleArchive}>
Archive collection
</button>
{/if}
</section>
{/if}
</div>
{:else if activeTab === 'fields'}
<!-- ── Fields Tab ──────────────────────────────────────── -->
<div class="tab-content">
{#if existingFields.length === 0 && newFields.length === 0}
<div class="empty-state">No fields defined yet.</div>
<div class="empty-state">
<div class="empty-state-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M3 12h18M3 18h12" />
</svg>
</div>
<h4 class="empty-state-title">No fields yet</h4>
<p class="empty-state-desc">
Fields define the structured data each item in this collection carries —
like status, priority, or a due date.
</p>
</div>
{:else}
<div class="fields-list">
{#each existingFields as field, i (field.key)}
@@ -718,7 +746,13 @@
</div>
{/each}
{:else}
<div class="empty-actions">No item actions defined.</div>
<div class="empty-actions">
<p>No per-item actions yet.</p>
<p class="empty-actions-hint">
Add one to surface a one-click agent prompt on every item in this
collection — e.g. "Summarize for standup" or "Draft release notes".
</p>
</div>
{/if}
</div>
@@ -753,7 +787,13 @@
</div>
{/each}
{:else}
<div class="empty-actions">No collection actions defined.</div>
<div class="empty-actions">
<p>No collection-level actions yet.</p>
<p class="empty-actions-hint">
Collection actions apply to the whole list — e.g. "Triage new items"
or "Archive completed".
</p>
</div>
{/if}
</div>
</div>
@@ -761,20 +801,6 @@
</div>
<div class="modal-footer">
{#if !collection.is_default}
{#if confirmArchive}
<span class="archive-confirm">
<span class="archive-warn">Archive "{collection.name}" and all its items?</span>
<button class="btn-archive-yes" type="button" onclick={handleArchive} disabled={archiving}>
{archiving ? 'Archiving...' : 'Yes, archive'}
</button>
<button class="btn-cancel-sm" type="button" onclick={() => confirmArchive = false}>Cancel</button>
</span>
{:else}
<button class="btn-archive" type="button" onclick={handleArchive}>Archive</button>
{/if}
{/if}
<span class="footer-spacer"></span>
<button class="btn-cancel" type="button" onclick={onclose}>Cancel</button>
<button
class="btn-save"
@@ -803,7 +829,8 @@
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 8vh;
padding: 8vh var(--space-4) var(--space-4);
animation: overlay-in 140ms ease-out;
}
.modal {
@@ -816,6 +843,21 @@
display: flex;
flex-direction: column;
max-height: 82vh;
animation: modal-in 160ms ease-out;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes modal-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.overlay, .modal { animation: none; }
}
/* ── Header ─────────────────────────────────────────────────────────────── */
@@ -917,35 +959,6 @@
gap: var(--space-3);
}
.icon-btn {
width: 48px;
height: 48px;
min-width: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius);
cursor: pointer;
font-size: 1.5em;
line-height: 1;
padding: 0;
}
.icon-btn:hover {
border-color: var(--border);
}
.icon-preview {
font-size: 1em;
}
.icon-placeholder {
color: var(--text-muted);
font-size: 0.8em;
}
.name-input {
flex: 1;
padding: var(--space-2) var(--space-3);
@@ -965,10 +978,6 @@
outline: none;
}
.emoji-picker-container {
margin-bottom: var(--space-2);
}
.form-group {
display: flex;
flex-direction: column;
@@ -976,9 +985,11 @@
}
.form-label {
font-size: 0.82em;
font-weight: 500;
font-size: 0.75em;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-input {
@@ -1046,11 +1057,35 @@
cursor: default;
}
/* ── Empty state (Fields tab) ─────────────────────────────────────────── */
.empty-state {
padding: var(--space-6) var(--space-4);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-8) var(--space-4);
}
.empty-state-icon {
color: var(--text-muted);
opacity: 0.5;
margin-bottom: var(--space-3);
}
.empty-state-title {
margin: 0 0 var(--space-2);
font-size: 1em;
font-weight: 600;
color: var(--text-secondary);
}
.empty-state-desc {
margin: 0;
max-width: 360px;
font-size: 0.85em;
line-height: 1.5;
color: var(--text-muted);
font-size: 0.88em;
}
/* ── Add field button ──────────────────────────────────────────────────── */
@@ -1161,71 +1196,119 @@
cursor: not-allowed;
}
.footer-spacer {
flex: 1;
/* ── Danger zone ───────────────────────────────────────────────────────── */
/*
* Lives at the bottom of the General tab. Visually separated by tint
* and border so destructive actions are unambiguously distinct from
* safe edits in the same tab.
*/
.danger-zone {
margin-top: var(--space-6);
padding: var(--space-4);
background: color-mix(in srgb, #ef4444 6%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, #ef4444 30%, var(--border));
border-radius: var(--radius);
}
.danger-zone-header {
margin-bottom: var(--space-3);
}
.danger-zone-title {
margin: 0 0 var(--space-1);
font-size: 0.82em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #ef4444;
}
.danger-zone-hint {
margin: 0;
font-size: 0.85em;
color: var(--text-secondary);
line-height: 1.5;
}
.btn-archive {
padding: var(--space-2) var(--space-4);
background: none;
border: 1px solid var(--border);
background: transparent;
border: 1px solid #ef4444;
border-radius: var(--radius);
color: var(--text-muted);
font-size: 0.85em;
color: #ef4444;
font-size: 0.88em;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-archive:hover {
border-color: #ef4444;
color: #ef4444;
background: rgba(239, 68, 68, 0.06);
background: #ef4444;
color: #fff;
}
.archive-confirm {
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.danger-zone-confirm-msg {
margin: 0;
font-size: 0.9em;
color: var(--text-primary);
}
.danger-zone-confirm-msg strong {
color: #ef4444;
}
.danger-zone-confirm-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.archive-warn {
font-size: 0.82em;
color: #ef4444;
font-weight: 500;
}
.btn-archive-yes {
padding: var(--space-1) var(--space-3);
.btn-archive-confirm {
padding: var(--space-2) var(--space-4);
background: #ef4444;
border: none;
border-radius: var(--radius-sm);
border: 1px solid #ef4444;
border-radius: var(--radius);
color: #fff;
font-size: 0.82em;
font-size: 0.88em;
font-weight: 500;
cursor: pointer;
}
.btn-archive-yes:hover:not(:disabled) {
filter: brightness(1.1);
.btn-archive-confirm:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn-archive-yes:disabled {
opacity: 0.5;
.btn-archive-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-cancel-sm {
padding: var(--space-1) var(--space-2);
background: none;
border: none;
color: var(--text-muted);
font-size: 0.82em;
.btn-archive-cancel {
padding: var(--space-2) var(--space-4);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 0.88em;
cursor: pointer;
}
.btn-cancel-sm:hover {
.btn-archive-cancel:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.btn-archive-cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Quick Actions tab ─────────────────────────────────────────────────── */
.tab-description {
@@ -1256,7 +1339,7 @@
}
.actions-section-title {
font-size: 0.8em;
font-size: 0.75em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1332,8 +1415,69 @@
}
.empty-actions {
font-size: 0.82em;
padding: var(--space-3) var(--space-4);
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
font-size: 0.85em;
line-height: 1.5;
}
.empty-actions p {
margin: 0;
}
.empty-actions .empty-actions-hint {
margin-top: var(--space-1);
color: var(--text-muted);
padding: var(--space-2) 0;
font-size: 0.92em;
}
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay {
padding: var(--space-3);
align-items: stretch;
}
.modal {
max-width: 100%;
max-height: calc(100vh - var(--space-6));
}
.modal-header,
.tab-content,
.modal-footer {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.tab-bar {
padding: 0 var(--space-4);
overflow-x: auto;
scrollbar-width: none;
/* Fade mask at the right edge hints more tabs scroll off */
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), transparent);
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab {
flex-shrink: 0;
}
.settings-grid {
grid-template-columns: 1fr;
}
.modal-footer {
flex-wrap: wrap;
gap: var(--space-2);
}
.btn-cancel, .btn-save {
flex: 1 1 auto;
}
}
</style>
@@ -228,37 +228,13 @@
>&#9660;</button>
</div>
{/if}
<div class="field-header-left">
<input
class="field-label-input"
type="text"
bind:value={field.label}
oninput={onLabelInput}
placeholder={isNew ? 'Field name' : 'Field label'}
/>
{#if isNew}
<div class="field-key-row">
<span class="field-key-prefix" aria-hidden="true">key</span>
<input
class="field-key-input"
class:has-error={!!keyError}
type="text"
bind:value={field.key}
oninput={onKeyInput}
placeholder="auto-generated from name"
spellcheck="false"
autocomplete="off"
aria-label="Field key"
aria-invalid={!!keyError}
/>
</div>
{#if keyError}
<div class="field-key-error" role="alert">{keyError}</div>
{:else if field.keyTouched}
<div class="field-key-hint">Keys can't be changed after save.</div>
{/if}
{/if}
</div>
<input
class="field-label-input"
type="text"
bind:value={field.label}
oninput={onLabelInput}
placeholder={isNew ? 'Field name' : 'Field label'}
/>
<select class="field-type-select" bind:value={field.type} title="Field type">
{#each FIELD_TYPES as ft (ft)}
<option value={ft}>{ft.replace('_', ' ')}</option>
@@ -273,6 +249,34 @@
>&#10005;</button>
</div>
{#if isNew}
<!--
Key row lives outside the header flex so the header stays
baseline-aligned at a fixed height regardless of whether a
key row is present. Full-width block below the header.
-->
<div class="field-key-block">
<span class="field-key-prefix" aria-hidden="true">key</span>
<input
class="field-key-input"
class:has-error={!!keyError}
type="text"
bind:value={field.key}
oninput={onKeyInput}
placeholder="auto-generated from name"
spellcheck="false"
autocomplete="off"
aria-label="Field key"
aria-invalid={!!keyError}
/>
</div>
{#if keyError}
<div class="field-key-error" role="alert">{keyError}</div>
{:else if field.keyTouched}
<div class="field-key-hint">Keys can't be changed after save.</div>
{/if}
{/if}
{#if isComputed}
<div class="field-computed-badge" title="Computed fields are populated by the server and can't be configured here.">
<span class="computed-dot" aria-hidden="true"></span>
@@ -569,16 +573,9 @@
cursor: default;
}
.field-header-left {
.field-label-input {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.field-label-input {
width: 100%;
padding: var(--space-1) var(--space-2);
background: var(--bg-secondary);
border: 1px solid transparent;
@@ -597,12 +594,19 @@
outline: none;
}
/* ── Key input row (new fields only) ───────────────────────────────────── */
/* ── Key block (new fields only) ───────────────────────────────────────── */
.field-key-row {
/*
* Full-width row below the header. Lives outside the header flex so
* the label / type-select / remove-button baseline stays consistent
* regardless of whether a key row is present.
*/
.field-key-block {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3) var(--space-2);
margin-top: calc(var(--space-3) * -1 + var(--space-1));
}
.field-key-prefix {
@@ -612,7 +616,9 @@
letter-spacing: 0.05em;
color: var(--text-muted);
font-family: var(--font-mono);
padding: 0 var(--space-2);
width: 28px;
text-align: right;
flex-shrink: 0;
}
.field-key-input {
@@ -644,15 +650,18 @@
background: color-mix(in srgb, var(--accent-red, #ef4444) 4%, transparent);
}
.field-key-error {
padding: 0 var(--space-2);
.field-key-error,
.field-key-hint {
padding: 0 var(--space-3) var(--space-2);
padding-left: calc(var(--space-3) + 28px + var(--space-2));
font-size: 0.72em;
}
.field-key-error {
color: var(--accent-red, #ef4444);
}
.field-key-hint {
padding: 0 var(--space-2);
font-size: 0.72em;
color: var(--text-muted);
font-style: italic;
}