3 Commits

Author SHA1 Message Date
d02d64b61c SdcTopBar: move app-directory grid to the left of the app name
Some checks failed
Publish package to GitHub Packages / publish (push) Has been cancelled
Grid (suite menu) now sits left of the app name; optional hamburger before it
(new onMenuClick prop) for apps with side nav; API-settings cog + no-key badge
stay on the right. Bump to 0.2.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:25:03 +10:00
ce1efd1c13 Add shared SDC app shell (SdcTopBar + ApiSettings + unified credentials)
Some checks failed
Publish package to GitHub Packages / publish (push) Has been cancelled
Workstream D: a shared top bar for the SDC tool suite — app name (left), API
settings cog, and the suite app directory (grid) — composed on the existing
TopBar. Adds an ApiSettings dialog and sdc_api_key/sdc_api_endpoint credential
helpers (shared once across all tools, with legacy-key migration). lucide-react
becomes a peer dependency. Bump to 0.2.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:44:00 +10:00
58a67dfc75 Update architecture: package distribution supersedes forks
Record the move from a fork-based downstream model to versioned npm-package
consumption (GitHub Packages, @richiesnitch/ads3-design-system).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:34:09 +10:00
11 changed files with 428 additions and 5 deletions

View File

@@ -6,7 +6,15 @@ This is the living architecture document for the ADS 3.0 design system. All stru
## 1. Overview ## 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.
--- ---

View File

@@ -1,6 +1,6 @@
{ {
"name": "@richiesnitch/ads3-design-system", "name": "@richiesnitch/ads3-design-system",
"version": "0.1.0", "version": "0.2.1",
"type": "module", "type": "module",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -33,6 +33,7 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"peerDependencies": { "peerDependencies": {
"lucide-react": "^1.16.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },

View 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)} />
</>
)
},
}

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { ApiSettings } from './ApiSettings'
export type { ApiSettingsProps } from './ApiSettings'

View 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' },
}

View File

@@ -0,0 +1,137 @@
import { useState, type ReactNode } from 'react'
import { Settings, Grid3x3, Menu } 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, to the right of the app-directory grid. */
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[]
/**
* If provided, renders a hamburger button at the very left (before the app-directory grid)
* that calls this on click — for apps that have a collapsible side navigation.
*/
onMenuClick?: () => void
}
/**
* Shared top bar for the SDC tool suite. Left → right: [optional hamburger] [app-directory grid]
* [app name] … [no-key badge] [API settings cog]. Wraps the ADS TopBar; uses the shared
* `sdc_*` credentials.
*/
export function SdcTopBar({ appName, activeTool, logo, tools = SDC_TOOLS, onMenuClick }: 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)
}
const leading = (
<div className="flex items-center gap-1 text-white">
{onMenuClick && (
<IconButton
icon={<Menu />}
aria-label="Toggle navigation"
variant="tertiary"
className="!text-white hover:!bg-white/10"
onClick={onMenuClick}
/>
)}
<Popover placement="bottom-start" 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>
)
return (
<>
<TopBar title={appName} logo={logo} leading={leading}>
<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)}
/>
</div>
</TopBar>
<ApiSettings
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => setHasCreds(checkCredentials())}
/>
</>
)
}

View File

@@ -0,0 +1,2 @@
export { SdcTopBar, SDC_TOOLS } from './SdcTopBar'
export type { SdcTopBarProps, SdcTool } from './SdcTopBar'

View File

@@ -1,6 +1,17 @@
// Utilities // Utilities
export { cn } from './lib/utils' 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 // Atoms
export { Button } from './components/atoms/Button' export { Button } from './components/atoms/Button'
export type { ButtonProps } 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 { TopBar } from './components/organisms/TopBar'
export type { TopBarProps } 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 { SideNav, SideNavItem, SideNavGroup, SideNavDivider } from './components/organisms/SideNav'
export type { export type {
SideNavProps, SideNavProps,

65
src/lib/credentials.ts Normal file
View 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 }
}

View File

@@ -34,9 +34,9 @@ export default defineConfig({
fileName: () => 'index.js', fileName: () => 'index.js',
}, },
rollupOptions: { rollupOptions: {
// React is a peer dep — externalise it (and the automatic JSX runtime) to avoid bundling a // React + lucide-react are peer deps (consumer apps already have them) — externalise to avoid
// second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled. // bundling a second copy. Everything else (clsx, tailwind-merge, @floating-ui) is small and bundled.
external: ['react', 'react-dom', 'react/jsx-runtime'], external: ['react', 'react-dom', 'react/jsx-runtime', 'lucide-react'],
output: { preserveModules: false }, output: { preserveModules: false },
}, },
sourcemap: true, sourcemap: true,