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 - -
  • - ))} + {/* Secondary metadata line (spec §02): status · date · size. The + indicator sits in a fixed-width leading slot so the date stays + aligned across rows whether or not a snippet is dirty. Draft is + flagged by presence + label, never colour alone (arch/10 §6). */} + + + {hasUnpublishedChanges(s) && ( + + )} + + {size ? `${date} · ${size}` : date} + + + + + ); + })} ); 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`; +}