Show size and a draft indicator in snippet-library rows

This commit is contained in:
2026-06-05 14:11:00 +03:00
parent da20475d2f
commit ad239d7d49
4 changed files with 138 additions and 50 deletions
+28 -14
View File
@@ -81,14 +81,9 @@
cursor: pointer; cursor: pointer;
} }
.nameRow {
position: relative;
display: flex;
align-items: center;
min-width: 0;
}
.name { .name {
display: block;
max-width: 100%;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
@@ -96,14 +91,29 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Unpublished-draft indicator (spec §03D / §02 status). Floated into the row's /* Secondary metadata line (spec §02): status indicator · relative date · size. */
left padding gutter and positioned out of flow so the title's start never .sub {
shifts — dirty and clean rows stay aligned with each other and with the date. */ display: flex;
align-items: center;
gap: var(--space-2);
}
/* Fixed-width leading slot for the status indicator. Always present (even when a
snippet is published and shows no dot) so the date never shifts and rows stay
aligned with one another. */
.indicator {
flex: 0 0 auto;
width: 6px;
height: 6px;
}
/* Unpublished-draft indicator (spec §02 status; arch/10 §6 + council). The accent
hue — not a warning colour, since unpublished work is a normal state — and the
meaning rides on presence/absence + the accessible label, never colour alone. */
.draftDot { .draftDot {
position: absolute; /* display:block so the 6px box applies — an inline span with no text content
left: calc(-1 * (var(--space-3) + var(--space-1))); would ignore width/height and collapse to nothing. */
top: 50%; display: block;
transform: translateY(-50%);
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
@@ -111,8 +121,12 @@
} }
.date { .date {
min-width: 0;
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.delete { .delete {
+49 -36
View File
@@ -2,12 +2,14 @@
* Snippet Library — the left pane (spec §02). * Snippet Library — the left pane (spec §02).
* *
* M1 scope: the always-visible list with a pinned "Create New Snippet" item, * M1 scope: the always-visible list with a pinned "Create New Snippet" item,
* selection/highlight, and delete. Search, sort controls, the metadata panel, * selection/highlight, and delete. Each row carries a secondary metadata line —
* status/dataset indicators, and the storage monitor arrive in later milestones. * draft status indicator, relative date, and size (spec §02). Search, sort
* controls, the metadata panel, the dataset icon, and the storage monitor arrive
* in later milestones.
*/ */
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { hasUnpublishedChanges } from '@core/snippet'; import { formatSnippetSize, hasUnpublishedChanges, snippetSizeBytes } from '@core/snippet';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import styles from './SnippetLibrary.module.css'; import styles from './SnippetLibrary.module.css';
@@ -63,40 +65,51 @@ export function SnippetLibrary() {
No snippets yet create your first one with the button above. No snippets yet create your first one with the button above.
</li> </li>
)} )}
{ordered.map((s) => ( {ordered.map((s) => {
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}> // Size is omitted under ~1 KB per spec §02; null collapses the suffix.
{/* The row's selectable area is a real <button> so it's keyboard const size = formatSnippetSize(snippetSizeBytes(s));
operable (Enter/Space) and focusable — not a click-only <li>. const date = relativeDate(s.modified);
Not a listbox option: APG forbids interactive children, and each return (
row carries a delete button. */} <li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
<button {/* The row's selectable area is a real <button> so it's keyboard
type="button" operable (Enter/Space) and focusable — not a click-only <li>.
className={styles.itemMain} Not a listbox option: APG forbids interactive children, and each
aria-current={s.id === activeId ? 'true' : undefined} row carries a delete button. */}
onClick={() => selectSnippet(s.id)} <button
> type="button"
<span className={styles.nameRow}> className={styles.itemMain}
{hasUnpublishedChanges(s) && ( aria-current={s.id === activeId ? 'true' : undefined}
<span onClick={() => selectSnippet(s.id)}
className={styles.draftDot} >
title="Has unpublished draft changes"
aria-label="Has unpublished draft changes"
/>
)}
<span className={styles.name}>{s.name}</span> <span className={styles.name}>{s.name}</span>
</span> {/* Secondary metadata line (spec §02): status · date · size. The
<span className={styles.date}>{relativeDate(s.modified)}</span> indicator sits in a fixed-width leading slot so the date stays
</button> aligned across rows whether or not a snippet is dirty. Draft is
<button flagged by presence + label, never colour alone (arch/10 §6). */}
className={styles.delete} <span className={styles.sub}>
aria-label={`Delete ${s.name}`} <span className={styles.indicator}>
title="Delete snippet" {hasUnpublishedChanges(s) && (
onClick={() => void handleDelete(s.id, s.name)} <span
> className={styles.draftDot}
title="Has unpublished draft changes"
</button> aria-label="Has unpublished draft changes"
</li> />
))} )}
</span>
<span className={styles.date}>{size ? `${date} · ${size}` : date}</span>
</span>
</button>
<button
className={styles.delete}
aria-label={`Delete ${s.name}`}
title="Delete snippet"
onClick={() => void handleDelete(s.id, s.name)}
>
</button>
</li>
);
})}
</ul> </ul>
</div> </div>
); );
+35
View File
@@ -2,10 +2,12 @@ import { describe, expect, test } from 'vitest';
import { import {
CURRENT_SNIPPET_VERSION, CURRENT_SNIPPET_VERSION,
createSnippet, createSnippet,
formatSnippetSize,
generateSnippetName, generateSnippetName,
hasUnpublishedChanges, hasUnpublishedChanges,
SAMPLE_SPEC, SAMPLE_SPEC,
sampleSpecText, sampleSpecText,
snippetSizeBytes,
} from './snippet'; } from './snippet';
describe('createSnippet', () => { describe('createSnippet', () => {
@@ -72,3 +74,36 @@ describe('hasUnpublishedChanges', () => {
expect(hasUnpublishedChanges({ ...s, draftSpec: s.spec + ' ' })).toBe(true); expect(hasUnpublishedChanges({ ...s, draftSpec: s.spec + ' ' })).toBe(true);
}); });
}); });
describe('snippetSizeBytes', () => {
test('measures the draft as UTF-8 byte length, not character count', () => {
const s = createSnippet({ spec: 'abc' });
expect(snippetSizeBytes(s)).toBe(3);
// A 3-character string of multibyte glyphs is more than 3 bytes.
expect(snippetSizeBytes({ ...s, draftSpec: '€€€' })).toBe(9);
});
test('reflects the working draft, not the published spec', () => {
const s = createSnippet({ spec: 'ab' });
expect(snippetSizeBytes({ ...s, draftSpec: 'abcdef' })).toBe(6);
});
});
describe('formatSnippetSize', () => {
test('omits the size under ~1 KB (spec §02 clutter rule)', () => {
expect(formatSnippetSize(0)).toBeNull();
expect(formatSnippetSize(512)).toBeNull();
expect(formatSnippetSize(1023)).toBeNull();
});
test('shows whole KB at and above the threshold', () => {
expect(formatSnippetSize(1024)).toBe('1 KB');
expect(formatSnippetSize(2048)).toBe('2 KB');
expect(formatSnippetSize(1536)).toBe('2 KB'); // rounds to nearest KB
});
test('rolls over to MB for large payloads', () => {
expect(formatSnippetSize(1024 * 1024)).toBe('1 MB');
expect(formatSnippetSize(3 * 1024 * 1024)).toBe('3 MB');
});
});
+26
View File
@@ -121,3 +121,29 @@ export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
export function hasUnpublishedChanges(snippet: Snippet): boolean { export function hasUnpublishedChanges(snippet: Snippet): boolean {
return snippet.draftSpec !== snippet.spec; return snippet.draftSpec !== snippet.spec;
} }
/**
* Approximate payload size of a snippet, in bytes (spec §09 `size`). Measured on
* the working draft — the version the editor and preview currently show — as its
* UTF-8 byte length, so the figure tracks what the user is actually editing.
* `TextEncoder` is a platform global (like `crypto.randomUUID` above), not a
* browser/DOM API, so it stays within the portable core.
*/
export function snippetSizeBytes(snippet: Snippet): number {
return new TextEncoder().encode(snippet.draftSpec).length;
}
/** Below this we omit the size in the library to reduce clutter (spec §02). */
const SIZE_DISPLAY_THRESHOLD = 1024;
/**
* Human-readable size for the library row, or `null` when the snippet is small
* enough that the spec says to omit it (under ~1 KB). Rounded to whole KB/MB —
* a list hint, not a precise measure.
*/
export function formatSnippetSize(bytes: number): string | null {
if (bytes < SIZE_DISPLAY_THRESHOLD) return null;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} KB`;
return `${Math.round(kb / 1024)} MB`;
}