mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Initial scaffold: spec, architecture playbook, and M0 skeleton
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1px var(--space-2);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pane:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.paneLabel {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import styles from './App.module.css';
|
||||
|
||||
/**
|
||||
* Application shell — the three-pane workspace from spec §01A
|
||||
* (library · editor · preview) under a fixed header.
|
||||
*
|
||||
* This is the skeleton: panes are placeholders. Each milestone fills one in
|
||||
* (see docs/IMPLEMENTATION-PLAN.md). Resizing, toggling, modals, routing, and
|
||||
* shortcuts arrive in later milestones.
|
||||
*/
|
||||
export function App() {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<header className={styles.header}>
|
||||
<span className={styles.title}>Astrolabe</span>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
<span className={styles.spacer} />
|
||||
</header>
|
||||
|
||||
<main className={styles.panes}>
|
||||
<section className={styles.pane} aria-label="Snippet library">
|
||||
<div className={styles.paneLabel}>Library</div>
|
||||
</section>
|
||||
<section className={styles.pane} aria-label="Spec editor">
|
||||
<div className={styles.paneLabel}>Editor</div>
|
||||
</section>
|
||||
<section className={styles.pane} aria-label="Live preview">
|
||||
<div className={styles.paneLabel}>Preview</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
|
||||
/**
|
||||
* Centralized cross-cutting application state, as a Zustand store. Keep this
|
||||
* lean — durable, feature-specific state (snippets, datasets, settings) lands
|
||||
* in its own store module (e.g. stores/SnippetStore) as the app grows.
|
||||
*
|
||||
* Usable inside React via the `useAppStore` hook (with a selector) and outside
|
||||
* React via `useAppStore.getState()` / `.setState()` / `.subscribe()` — see
|
||||
* docs/architecture/01-state-and-stores.md.
|
||||
*/
|
||||
|
||||
export type { UiTheme };
|
||||
|
||||
/** Which modal, if any, is currently open. At most one at a time (spec §01C). */
|
||||
export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBuilder' | 'extract';
|
||||
|
||||
export interface AppState {
|
||||
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
||||
uiTheme: UiTheme;
|
||||
/** The currently open modal, or null. */
|
||||
activeModal: ModalName | null;
|
||||
|
||||
setTheme: (theme: UiTheme) => void;
|
||||
/**
|
||||
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||
* discard confirmation) lives in the modal coordinator (docs/architecture/03),
|
||||
* which calls this; arrives with the modal system in M3.
|
||||
*/
|
||||
setActiveModal: (modal: ModalName | null) => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
uiTheme: 'light',
|
||||
activeModal: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
setActiveModal: (activeModal) => set({ activeModal }),
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectFormat, detectFormatFromUrl } from './format-detection';
|
||||
|
||||
describe('detectFormat', () => {
|
||||
it('detects a JSON array of objects with high confidence', () => {
|
||||
expect(detectFormat('[{"a":1},{"a":2}]')).toEqual({ format: 'json', confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects a single JSON object as json', () => {
|
||||
expect(detectFormat('{"a":1}')).toEqual({ format: 'json', confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects a TopoJSON topology object', () => {
|
||||
const topo = JSON.stringify({ type: 'Topology', objects: {}, arcs: [] });
|
||||
expect(detectFormat(topo)).toEqual({ format: 'topojson', confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects CSV from a comma-separated header + row with medium confidence', () => {
|
||||
expect(detectFormat('a,b,c\n1,2,3')).toEqual({ format: 'csv', confidence: 'medium' });
|
||||
});
|
||||
|
||||
it('detects TSV from a tab-separated header + row with medium confidence', () => {
|
||||
expect(detectFormat('a\tb\tc\n1\t2\t3')).toEqual({ format: 'tsv', confidence: 'medium' });
|
||||
});
|
||||
|
||||
it('prefers TSV over CSV when both delimiters appear in the header', () => {
|
||||
expect(detectFormat('a\tb,c\n1\t2,3').format).toBe('tsv');
|
||||
});
|
||||
|
||||
it('returns low confidence / null for unrecognized input', () => {
|
||||
expect(detectFormat('just a sentence')).toEqual({ format: null, confidence: 'low' });
|
||||
});
|
||||
|
||||
it('returns low confidence / null for empty input', () => {
|
||||
expect(detectFormat(' ')).toEqual({ format: null, confidence: 'low' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFormatFromUrl', () => {
|
||||
it.each([
|
||||
['https://example.com/data.csv', 'csv'],
|
||||
['https://example.com/data.tsv', 'tsv'],
|
||||
['https://example.com/data.json', 'json'],
|
||||
['https://example.com/world.topojson', 'topojson'],
|
||||
['https://example.com/data.csv?v=2', 'csv'],
|
||||
])('infers format from %s', (url, expected) => {
|
||||
expect(detectFormatFromUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null when no known extension is present', () => {
|
||||
expect(detectFormatFromUrl('https://example.com/data')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Format auto-detection for pasted dataset data.
|
||||
*
|
||||
* Portable core: no browser APIs, no React — usable in Node and tests.
|
||||
* Implements the detection rules from spec §05 (Datasets → Auto-detection):
|
||||
*
|
||||
* - Valid JSON parses to `json`, or `topojson` when it is a topology object — high confidence.
|
||||
* - Otherwise multi-line text with a header row is `tsv` (tab-separated) or `csv` (comma) — medium.
|
||||
* - Unrecognized input yields `null` format — low confidence (saving should be blocked upstream).
|
||||
*/
|
||||
|
||||
export type DataFormat = 'json' | 'csv' | 'tsv' | 'topojson';
|
||||
export type DetectionConfidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface FormatDetection {
|
||||
format: DataFormat | null;
|
||||
confidence: DetectionConfidence;
|
||||
}
|
||||
|
||||
function isTopology(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as { type?: unknown }).type === 'Topology'
|
||||
);
|
||||
}
|
||||
|
||||
/** Detect the format of pasted inline data. */
|
||||
export function detectFormat(raw: string): FormatDetection {
|
||||
const text = raw.trim();
|
||||
if (text === '') return { format: null, confidence: 'low' };
|
||||
|
||||
// 1. Try JSON first — highest confidence signal.
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return {
|
||||
format: isTopology(parsed) ? 'topojson' : 'json',
|
||||
confidence: 'high',
|
||||
};
|
||||
} catch {
|
||||
// not JSON — fall through to delimited detection
|
||||
}
|
||||
|
||||
// 2. Delimited text: needs at least a header row plus one data row.
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||||
if (lines.length >= 2) {
|
||||
const header = lines[0];
|
||||
if (header.includes('\t')) return { format: 'tsv', confidence: 'medium' };
|
||||
if (header.includes(',')) return { format: 'csv', confidence: 'medium' };
|
||||
}
|
||||
|
||||
// 3. Unrecognized.
|
||||
return { format: null, confidence: 'low' };
|
||||
}
|
||||
|
||||
/** Infer a dataset's format from a URL's file extension (spec §05). */
|
||||
export function detectFormatFromUrl(url: string): DataFormat | null {
|
||||
const match = url.toLowerCase().match(/\.(csv|tsv|json|topojson)(?:[?#]|$)/);
|
||||
if (!match) return null;
|
||||
return match[1] as DataFormat;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Theme identity — portable core. No browser APIs, no React.
|
||||
*
|
||||
* `UiTheme` is the app-wide theme token. It lives in `src/core/` (not in a
|
||||
* store) because pure core logic needs it too: the Vega chart-config mapping
|
||||
* (`chartConfigFor`, see docs/architecture/05) keys off the same union, and core
|
||||
* must never import from `src/app/`. The store and the document `data-theme`
|
||||
* mirror this value; this is its single definition.
|
||||
*/
|
||||
export type UiTheme = 'light' | 'experimental';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import { useAppStore } from './app/stores/AppStore';
|
||||
import '../styles/base.css';
|
||||
|
||||
// Mirror the UI theme onto <html data-theme>: apply the initial value before
|
||||
// first paint, then keep it in sync. (Store stays DOM-free; the adapter is here.)
|
||||
const applyTheme = (theme: string) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
};
|
||||
applyTheme(useAppStore.getState().uiTheme);
|
||||
useAppStore.subscribe((state, prev) => {
|
||||
if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('app')!).render(<App />);
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: { readonly [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
Reference in New Issue
Block a user