mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Show size and a draft indicator in snippet-library rows
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user