Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce1efd1c13 | |||
| 58a67dfc75 |
@@ -6,7 +6,15 @@ This is the living architecture document for the ADS 3.0 design system. All stru
|
||||
|
||||
## 1. Overview
|
||||
|
||||
ADS 3.0 Design System is a React component library implementing the ADS 3.0 (Adaptive Design System) design language. It provides tokens, primitives, and composite components as a shared foundation. Application-specific screens and domain logic belong in downstream forks of this repo.
|
||||
ADS 3.0 Design System is a React component library implementing the ADS 3.0 (Adaptive Design System) design language. It provides tokens, primitives, and composite components as a shared foundation. Application-specific screens and domain logic belong in **consuming applications**, not in this repo.
|
||||
|
||||
### Distribution
|
||||
|
||||
ADS is distributed as a **versioned npm package** consumed by downstream apps — not by forking this repo. (The earlier fork-based model has been superseded.)
|
||||
|
||||
- Published to **GitHub Packages** as `@richiesnitch/ads3-design-system` from `Richiesnitch/ads3-design-system`; a publish-on-tag GitHub Action runs `build:lib` and `npm publish`.
|
||||
- Built via `npm run build:lib` (`vite.lib.config.ts`): bundles `src/index.ts` to ESM with React/react-dom externalised and internal `@/` imports resolved at build time; emits type declarations and copies `tokens.css` / `typography.css` into `dist/`.
|
||||
- Consumers import components from the package entry, `@richiesnitch/ads3-design-system/tokens` + `/typography` for the design-token CSS, and point Tailwind's `@source` at the built bundle to generate utilities. No `@/` alias or sibling-folder clone required.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@richiesnitch/ads3-design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,6 +33,7 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
25
src/components/organisms/ApiSettings/ApiSettings.stories.tsx
Normal file
25
src/components/organisms/ApiSettings/ApiSettings.stories.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { ApiSettings } from './ApiSettings'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
|
||||
const meta: Meta<typeof ApiSettings> = {
|
||||
title: 'Organisms/ApiSettings',
|
||||
component: ApiSettings,
|
||||
tags: ['autodocs'],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ApiSettings>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Open API settings</Button>
|
||||
<ApiSettings open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
146
src/components/organisms/ApiSettings/ApiSettings.tsx
Normal file
146
src/components/organisms/ApiSettings/ApiSettings.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from '@/components/molecules/Dialog'
|
||||
import { Button } from '@/components/atoms/Button'
|
||||
import { Input } from '@/components/atoms/Input'
|
||||
import { Alert } from '@/components/molecules/Alert'
|
||||
import { Badge } from '@/components/atoms/Badge'
|
||||
import {
|
||||
getEndpoint,
|
||||
getApiKey,
|
||||
saveCredentials,
|
||||
testConnection,
|
||||
} from '@/lib/credentials'
|
||||
|
||||
export interface ApiSettingsProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** Called after credentials are saved (e.g. to refresh a "no API key" badge). */
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared API-settings dialog for the SDC tool suite. Reads/writes the suite-wide
|
||||
* `sdc_api_endpoint` + `sdc_api_key` so the key is entered once across all tools.
|
||||
*/
|
||||
export function ApiSettings({ open, onClose, onSaved }: ApiSettingsProps) {
|
||||
const [endpoint, setEndpoint] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [status, setStatus] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEndpoint(getEndpoint())
|
||||
setApiKey(getApiKey())
|
||||
setStatus(null)
|
||||
setModels([])
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function handleSave() {
|
||||
if (!endpoint.trim()) {
|
||||
setStatus({ type: 'error', message: 'Please enter a gateway endpoint.' })
|
||||
return
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setStatus({ type: 'error', message: 'Please enter an API key.' })
|
||||
return
|
||||
}
|
||||
saveCredentials(endpoint, apiKey)
|
||||
onSaved?.()
|
||||
setStatus({ type: 'success', message: 'Credentials saved. They apply across all SDC tools.' })
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
if (!endpoint.trim() || !apiKey.trim()) {
|
||||
setStatus({ type: 'error', message: 'Enter an endpoint and key first.' })
|
||||
return
|
||||
}
|
||||
saveCredentials(endpoint, apiKey)
|
||||
onSaved?.()
|
||||
setTesting(true)
|
||||
setStatus({ type: 'info', message: 'Testing connection…' })
|
||||
setModels([])
|
||||
try {
|
||||
const result = await testConnection(endpoint, apiKey)
|
||||
setModels(result.models)
|
||||
setStatus({
|
||||
type: 'success',
|
||||
message: 'Connected. ' + result.models.length + ' model(s) available.',
|
||||
})
|
||||
} catch (e) {
|
||||
setStatus({ type: 'error', message: (e as Error).message })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} size="lg">
|
||||
<DialogHeader onClose={onClose}>
|
||||
<DialogTitle>API Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your gateway connection. This is shared across all SDC tools.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
variant="stacked"
|
||||
label="Gateway Endpoint"
|
||||
description="The base URL of your LiteLLM gateway"
|
||||
type="url"
|
||||
placeholder="https://your-gateway.example.com"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
variant="stacked"
|
||||
label="API Key"
|
||||
type="password"
|
||||
placeholder="Paste your API key here"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Alert
|
||||
variant={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'}
|
||||
title={status.type === 'success' ? 'Success' : status.type === 'error' ? 'Error' : ''}
|
||||
onClose={() => setStatus(null)}
|
||||
>
|
||||
{status.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-small font-semibold text-text-secondary">Available Models</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<Badge key={m} variant="info-light">{m}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleTest} loading={testing}>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
2
src/components/organisms/ApiSettings/index.ts
Normal file
2
src/components/organisms/ApiSettings/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ApiSettings } from './ApiSettings'
|
||||
export type { ApiSettingsProps } from './ApiSettings'
|
||||
20
src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx
Normal file
20
src/components/organisms/SdcTopBar/SdcTopBar.stories.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { SdcTopBar } from './SdcTopBar'
|
||||
|
||||
const meta: Meta<typeof SdcTopBar> = {
|
||||
title: 'Organisms/SdcTopBar',
|
||||
component: SdcTopBar,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SdcTopBar>
|
||||
|
||||
export const Default: Story = {
|
||||
args: { appName: 'Status Report', activeTool: 'status-report' },
|
||||
}
|
||||
|
||||
export const Synthesiser: Story = {
|
||||
args: { appName: 'Research Synthesiser', activeTool: 'synthesiser' },
|
||||
}
|
||||
118
src/components/organisms/SdcTopBar/SdcTopBar.tsx
Normal file
118
src/components/organisms/SdcTopBar/SdcTopBar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { Settings, Grid3x3 } from 'lucide-react'
|
||||
import { TopBar } from '@/components/organisms/TopBar'
|
||||
import { IconButton } from '@/components/atoms/IconButton'
|
||||
import { Badge } from '@/components/atoms/Badge'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/molecules/Popover'
|
||||
import { List, ListItem, ListSubheader, ListDivider } from '@/components/atoms/List'
|
||||
import { ApiSettings } from '@/components/organisms/ApiSettings'
|
||||
import { hasCredentials as checkCredentials } from '@/lib/credentials'
|
||||
|
||||
export interface SdcTool {
|
||||
/** Stable identifier, used to mark the current tool active. */
|
||||
slug: string
|
||||
label: string
|
||||
/** Relative link from one tool folder to another, e.g. '../SDC Status Report/index.html'. */
|
||||
href: string
|
||||
group: string
|
||||
}
|
||||
|
||||
/** The SDC tool suite directory. Adding a tool here updates the menu in every app. */
|
||||
export const SDC_TOOLS: SdcTool[] = [
|
||||
{ slug: 'synthesiser', label: 'Research Synthesiser', group: 'HCD Tools', href: '../SDC Project Synthesiser/index.html' },
|
||||
{ slug: 'data-synthesis', label: 'Data Synthesis', group: 'HCD Tools', href: '../SDC Data Synthesis/index.html' },
|
||||
{ slug: 'persona', label: 'Persona Builder', group: 'HCD Tools', href: '../SDC Persona Builder/index.html' },
|
||||
{ slug: 'case-study', label: 'Case Study Generator', group: 'HCD Tools', href: '../SDC Case Study Generator/index.html' },
|
||||
{ slug: 'charter', label: 'Project Charter', group: 'Project Management', href: '../SDC Project Charter/index.html' },
|
||||
{ slug: 'timeline', label: 'Timeline Builder', group: 'Project Management', href: '../SDC Timeline Builder/index.html' },
|
||||
{ slug: 'status-report', label: 'Status Report', group: 'Project Management', href: '../SDC Status Report/index.html' },
|
||||
]
|
||||
|
||||
export interface SdcTopBarProps {
|
||||
/** App name shown top-left. */
|
||||
appName: string
|
||||
/** Slug of the current tool, marked active and non-navigating in the menu. */
|
||||
activeTool?: string
|
||||
/** Optional logo node rendered before the app name. */
|
||||
logo?: ReactNode
|
||||
/** Override the tool list (defaults to the full SDC suite). */
|
||||
tools?: SdcTool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared top bar for the SDC tool suite: app name (left), API settings (cog), and the
|
||||
* suite app directory (grid). Wraps the ADS TopBar; uses the shared `sdc_*` credentials.
|
||||
*/
|
||||
export function SdcTopBar({ appName, activeTool, logo, tools = SDC_TOOLS }: SdcTopBarProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [toolsOpen, setToolsOpen] = useState(false)
|
||||
const [hasCreds, setHasCreds] = useState(() => checkCredentials())
|
||||
|
||||
// Group tools preserving first-seen group order.
|
||||
const groups: { name: string; items: SdcTool[] }[] = []
|
||||
for (const t of tools) {
|
||||
let g = groups.find((x) => x.name === t.group)
|
||||
if (!g) { g = { name: t.group, items: [] }; groups.push(g) }
|
||||
g.items.push(t)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title={appName} logo={logo}>
|
||||
<div className="flex items-center gap-1 text-white">
|
||||
{!hasCreds && <Badge variant="warning-light">No API key</Badge>}
|
||||
|
||||
<IconButton
|
||||
icon={<Settings />}
|
||||
aria-label="API settings"
|
||||
variant="tertiary"
|
||||
className="!text-white hover:!bg-white/10"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
/>
|
||||
|
||||
<Popover placement="bottom-end" open={toolsOpen} onOpenChange={setToolsOpen}>
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
icon={<Grid3x3 />}
|
||||
aria-label="SDC AI Tools"
|
||||
variant="tertiary"
|
||||
className="!text-white hover:!bg-white/10"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div className="w-64">
|
||||
<List>
|
||||
{groups.map((group) => (
|
||||
<div key={group.name}>
|
||||
<ListSubheader>{group.name}</ListSubheader>
|
||||
{group.items.map((item) => {
|
||||
const isActive = item.slug === activeTool
|
||||
return (
|
||||
<ListItem
|
||||
key={item.slug}
|
||||
active={isActive}
|
||||
href={isActive ? undefined : item.href}
|
||||
onClick={() => isActive && setToolsOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
<ListDivider />
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TopBar>
|
||||
|
||||
<ApiSettings
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={() => setHasCreds(checkCredentials())}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
2
src/components/organisms/SdcTopBar/index.ts
Normal file
2
src/components/organisms/SdcTopBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { SdcTopBar, SDC_TOOLS } from './SdcTopBar'
|
||||
export type { SdcTopBarProps, SdcTool } from './SdcTopBar'
|
||||
17
src/index.ts
17
src/index.ts
@@ -1,6 +1,17 @@
|
||||
// Utilities
|
||||
export { cn } from './lib/utils'
|
||||
|
||||
// SDC suite: shared credentials (sdc_api_key / sdc_api_endpoint)
|
||||
export {
|
||||
getApiKey,
|
||||
getEndpoint,
|
||||
saveCredentials,
|
||||
hasCredentials,
|
||||
migrateLegacyCredentials,
|
||||
testConnection,
|
||||
} from './lib/credentials'
|
||||
export type { TestConnectionResult } from './lib/credentials'
|
||||
|
||||
// Atoms
|
||||
export { Button } from './components/atoms/Button'
|
||||
export type { ButtonProps } from './components/atoms/Button'
|
||||
@@ -112,6 +123,12 @@ export type { DataTableProps, DataTableColumn } from './components/molecules/Dat
|
||||
export { TopBar } from './components/organisms/TopBar'
|
||||
export type { TopBarProps } from './components/organisms/TopBar'
|
||||
|
||||
export { SdcTopBar, SDC_TOOLS } from './components/organisms/SdcTopBar'
|
||||
export type { SdcTopBarProps, SdcTool } from './components/organisms/SdcTopBar'
|
||||
|
||||
export { ApiSettings } from './components/organisms/ApiSettings'
|
||||
export type { ApiSettingsProps } from './components/organisms/ApiSettings'
|
||||
|
||||
export { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './components/organisms/SideNav'
|
||||
export type {
|
||||
SideNavProps,
|
||||
|
||||
65
src/lib/credentials.ts
Normal file
65
src/lib/credentials.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Suite-wide API credentials, shared across all SDC tools on one origin via localStorage.
|
||||
// Enter the key once (in any tool) and every tool picks it up.
|
||||
|
||||
const LS_ENDPOINT = 'sdc_api_endpoint'
|
||||
const LS_KEY = 'sdc_api_key'
|
||||
|
||||
export function getEndpoint(): string {
|
||||
return (localStorage.getItem(LS_ENDPOINT) ?? '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function getApiKey(): string {
|
||||
return localStorage.getItem(LS_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function saveCredentials(endpoint: string, apiKey: string): void {
|
||||
localStorage.setItem(LS_ENDPOINT, endpoint.replace(/\/+$/, ''))
|
||||
localStorage.setItem(LS_KEY, apiKey)
|
||||
}
|
||||
|
||||
export function hasCredentials(): boolean {
|
||||
return !!(getEndpoint() && getApiKey())
|
||||
}
|
||||
|
||||
// One-time migration: if the unified key isn't set yet but a tool's old per-app key exists,
|
||||
// copy it across so existing users aren't logged out. Call once on app startup with the
|
||||
// tool's legacy localStorage key names. Returns true if a migration happened.
|
||||
export function migrateLegacyCredentials(legacyApiKeyName: string, legacyEndpointName?: string): boolean {
|
||||
if (getApiKey()) return false
|
||||
const oldKey = localStorage.getItem(legacyApiKeyName)
|
||||
if (!oldKey) return false
|
||||
const oldEndpoint = legacyEndpointName ? localStorage.getItem(legacyEndpointName) : null
|
||||
saveCredentials((oldEndpoint ?? getEndpoint()).replace(/\/+$/, ''), oldKey)
|
||||
return true
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
models: string[]
|
||||
}
|
||||
|
||||
// Generic OpenAI-compatible credential check: lists models at <endpoint>/v1/models.
|
||||
export async function testConnection(endpoint: string, apiKey: string): Promise<TestConnectionResult> {
|
||||
const base = endpoint.replace(/\/+$/, '')
|
||||
if (!base) throw new Error('Please enter a gateway endpoint.')
|
||||
if (!apiKey) throw new Error('Please enter an API key.')
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(base + '/v1/models', {
|
||||
headers: { Authorization: 'Bearer ' + apiKey },
|
||||
})
|
||||
} catch {
|
||||
throw new Error('Could not reach the gateway. Check the endpoint URL and your network.')
|
||||
}
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error('Authentication failed (HTTP ' + res.status + '). Check your API key.')
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error('Connection failed (HTTP ' + res.status + ').')
|
||||
}
|
||||
const data = await res.json().catch(() => ({}))
|
||||
const models: string[] = Array.isArray(data?.data)
|
||||
? data.data.map((m: { id?: string }) => m.id).filter((id: unknown): id is string => typeof id === 'string')
|
||||
: []
|
||||
return { models }
|
||||
}
|
||||
@@ -34,9 +34,9 @@ export default defineConfig({
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
// React is a peer dep — externalise it (and the automatic JSX runtime) to avoid bundling a
|
||||
// second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
// React + lucide-react are peer deps (consumer apps already have them) — externalise to avoid
|
||||
// bundling a second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled.
|
||||
external: ['react', 'react-dom', 'react/jsx-runtime', 'lucide-react'],
|
||||
output: { preserveModules: false },
|
||||
},
|
||||
sourcemap: true,
|
||||
|
||||
Reference in New Issue
Block a user