diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css
index b8f2840..769121a 100644
--- a/src/app/components/SnippetLibrary.module.css
+++ b/src/app/components/SnippetLibrary.module.css
@@ -81,14 +81,9 @@
cursor: pointer;
}
-.nameRow {
- position: relative;
- display: flex;
- align-items: center;
- min-width: 0;
-}
-
.name {
+ display: block;
+ max-width: 100%;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
@@ -96,14 +91,29 @@
text-overflow: ellipsis;
}
-/* Unpublished-draft indicator (spec §03D / §02 status). Floated into the row's
- left padding gutter and positioned out of flow so the title's start never
- shifts — dirty and clean rows stay aligned with each other and with the date. */
+/* Secondary metadata line (spec §02): status indicator · relative date · size. */
+.sub {
+ 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 {
- position: absolute;
- left: calc(-1 * (var(--space-3) + var(--space-1)));
- top: 50%;
- transform: translateY(-50%);
+ /* display:block so the 6px box applies — an inline span with no text content
+ would ignore width/height and collapse to nothing. */
+ display: block;
width: 6px;
height: 6px;
border-radius: 50%;
@@ -111,8 +121,12 @@
}
.date {
+ min-width: 0;
font-size: 11px;
color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.delete {
diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx
index 7e55195..5f297e7 100644
--- a/src/app/components/SnippetLibrary.tsx
+++ b/src/app/components/SnippetLibrary.tsx
@@ -2,12 +2,14 @@
* Snippet Library — the left pane (spec §02).
*
* M1 scope: the always-visible list with a pinned "Create New Snippet" item,
- * selection/highlight, and delete. Search, sort controls, the metadata panel,
- * status/dataset indicators, and the storage monitor arrive in later milestones.
+ * selection/highlight, and delete. Each row carries a secondary metadata line —
+ * 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 { hasUnpublishedChanges } from '@core/snippet';
+import { formatSnippetSize, hasUnpublishedChanges, snippetSizeBytes } from '@core/snippet';
import { confirm } from '../stores/ConfirmStore';
import { useSnippetStore } from '../stores/SnippetStore';
import styles from './SnippetLibrary.module.css';
@@ -63,40 +65,51 @@ export function SnippetLibrary() {
No snippets yet — create your first one with the button above.
)}
- {ordered.map((s) => (
-
- {/* The row's selectable area is a real
+ void handleDelete(s.id, s.name)}
+ >
+ ✕
+
+
+ );
+ })}
);
diff --git a/src/core/snippet.test.ts b/src/core/snippet.test.ts
index 6b255fd..f297658 100644
--- a/src/core/snippet.test.ts
+++ b/src/core/snippet.test.ts
@@ -2,10 +2,12 @@ import { describe, expect, test } from 'vitest';
import {
CURRENT_SNIPPET_VERSION,
createSnippet,
+ formatSnippetSize,
generateSnippetName,
hasUnpublishedChanges,
SAMPLE_SPEC,
sampleSpecText,
+ snippetSizeBytes,
} from './snippet';
describe('createSnippet', () => {
@@ -72,3 +74,36 @@ describe('hasUnpublishedChanges', () => {
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');
+ });
+});
diff --git a/src/core/snippet.ts b/src/core/snippet.ts
index 6cb9e5a..182aa1d 100644
--- a/src/core/snippet.ts
+++ b/src/core/snippet.ts
@@ -121,3 +121,29 @@ export function createSnippet(options: CreateSnippetOptions = {}): Snippet {
export function hasUnpublishedChanges(snippet: Snippet): boolean {
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`;
+}