feat: release toolbox portal 0.1.0

This commit is contained in:
2026-07-20 18:40:51 +02:00
commit 5d2e466ad6
55 changed files with 11127 additions and 0 deletions

122
src/App.test.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import App from './App';
import { PREFERENCES_KEY } from './preferences';
import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals());
describe('portal UI', () => {
it('renders app cards and persists accessible pin/hide controls', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
expect(
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
expect(
screen.getByText(
'Local processing · no file uploads declared · no telemetry declared'
)
).toBeInTheDocument();
const sourceLinks = screen.getAllByRole('link', {
name: 'Source and license',
});
expect(sourceLinks).toHaveLength(2);
expect(
sourceLinks.find(
(link) =>
link.getAttribute('href') ===
'https://git.add-ideas.de/zemion/pdf-tools'
)
).toHaveAttribute('rel', 'noopener noreferrer');
expect(
sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0')
)
).toBeDefined();
const launch = screen.getByRole('link', { name: /open tool/i });
expect(
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
).toContain('toolbox.catalog.json');
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
await waitFor(() =>
expect(localStorage.getItem(PREFERENCES_KEY)).toContain(
'de.add-ideas.pdf-tools'
)
);
expect(screen.getByText('Pinned')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
});
it('shows a recoverable catalogue error', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('no', { status: 503 }))
.mockImplementation(catalogueFetch());
vi.stubGlobal('fetch', fetchMock);
render(<App />);
expect(
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
});
it('shows a dedicated empty state for a valid catalogue', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
catalogueFetch({
'/toolbox.catalog.json': Response.json({ ...catalogue, apps: [] }),
})
)
);
render(<App />);
expect(
await screen.findByRole('heading', { name: 'No tools yet' })
).toBeInTheDocument();
});
it('contains preference focus and restores it to Personalize', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize);
const dialog = screen.getByRole('dialog', {
name: 'Personalize your toolbox',
});
const close = screen.getByRole('button', { name: 'Close preferences' });
expect(close).toHaveFocus();
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(dialog).toBeInTheDocument();
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
/Trusted code in another Toolbox app on this site can access same-origin storage/i
);
const reset = screen.getByRole('button', { name: 'Reset' });
reset.focus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(personalize).toHaveFocus();
});
});

356
src/App.tsx Normal file
View File

@@ -0,0 +1,356 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppCard } from './components/AppCard';
import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel';
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
import { cleanPreferences, sortAppIds } from './preferences';
import type { LoadedCatalogue, ResolvedApp } from './types';
import { usePreferences } from './usePreferences';
const CATALOGUE_HREF = './toolbox.catalog.json';
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue };
function appMatches(
app: ResolvedApp,
query: string,
category: string
): boolean {
if (category && !app.categories.includes(category)) return false;
if (!query) return true;
const haystack = [app.name, app.description, ...app.categories, ...app.tags]
.join(' ')
.toLocaleLowerCase();
return haystack.includes(query.toLocaleLowerCase());
}
export default function App() {
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
const [reloadKey, setReloadKey] = useState(0);
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [showHidden, setShowHidden] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeSettings = useCallback(() => setSettingsOpen(false), []);
useEffect(() => {
const controller = new AbortController();
loadCatalogue(CATALOGUE_HREF, controller.signal).then(
(value) => setLoadState({ status: 'ready', value }),
(error) => {
if (controller.signal.aborted) return;
setLoadState({
status: 'error',
message:
error instanceof Error
? error.message
: 'The catalogue could not be loaded.',
});
}
);
return () => controller.abort();
}, [reloadKey]);
const loaded = loadState.status === 'ready' ? loadState.value : undefined;
const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
useEffect(() => {
if (!loaded) return;
const clean = cleanPreferences(
preferences,
loaded.apps.map((app) => app.id)
);
if (JSON.stringify(clean) !== JSON.stringify(preferences))
setPreferences(clean);
}, [loaded, preferences, setPreferences]);
useEffect(() => {
document.documentElement.dataset.theme = preferences.theme;
}, [preferences.theme]);
const categories = useMemo(
() =>
[...new Set(apps.flatMap((app) => app.categories))].sort((a, b) =>
a.localeCompare(b)
),
[apps]
);
const orderedApps = useMemo(() => {
const byId = new Map(apps.map((app) => [app.id, app]));
return sortAppIds(
apps.map((app) => app.id),
preferences
).flatMap((id) => {
const app = byId.get(id);
return app ? [app] : [];
});
}, [apps, preferences]);
const visibleApps = orderedApps.filter((app) => {
const hidden = preferences.hidden.includes(app.id);
return (showHidden || !hidden) && appMatches(app, query.trim(), category);
});
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
setPreferences((current) => ({
...current,
[field]: current[field].includes(id)
? current[field].filter((item) => item !== id)
: [...current[field], id],
}));
}
function moveApp(id: string, direction: -1 | 1) {
const pinned = preferences.pinned.includes(id);
const peers = visibleApps.filter(
(app) => preferences.pinned.includes(app.id) === pinned
);
const currentIndex = peers.findIndex((app) => app.id === id);
const target = peers[currentIndex + direction];
if (!target) return;
setPreferences((current) => {
const currentPosition = current.order.indexOf(id);
const targetPosition = current.order.indexOf(target.id);
if (currentPosition < 0 || targetPosition < 0) return current;
const order = [...current.order];
[order[currentPosition], order[targetPosition]] = [
order[targetPosition]!,
order[currentPosition]!,
];
return { ...current, order };
});
}
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas';
return (
<>
<div
className="site-shell"
inert={settingsOpen}
aria-hidden={settingsOpen || undefined}
>
<a className="skip-link" href="#main-content">
Skip to tools
</a>
<header className="site-header">
<div className="header-inner">
<a
className="brand"
href={loaded ? loaded.homeUrl : './'}
aria-label={`${title} home`}
>
<Logo />
<span>
<strong>{brand}</strong>
<small>Toolbox</small>
</span>
</a>
<button
className="settings-button"
type="button"
onClick={() => setSettingsOpen(true)}
>
<span aria-hidden="true"></span> Personalize
</button>
</div>
</header>
<main id="main-content">
<section className="hero" aria-labelledby="page-title">
<p className="eyebrow">
Privacy details up front · useful by default
</p>
<h1 id="page-title">
Your document tools,
<br />
<span>in one calm place.</span>
</h1>
<p className="hero-copy">
Focused browser utilities with a privacy disclosure for each tool.
Choose one and keep your work moving.
</p>
</section>
{loadState.status === 'loading' && (
<section className="state-card" aria-live="polite">
<div className="loader" aria-hidden="true" />
<h2>Loading your toolbox</h2>
<p>Reading the local app catalogue</p>
</section>
)}
{loadState.status === 'error' && (
<section className="state-card state-card--error" role="alert">
<span className="state-icon" aria-hidden="true">
!
</span>
<h2>Toolbox unavailable</h2>
<p>{loadState.message}</p>
<button
type="button"
onClick={() => {
setLoadState({ status: 'loading' });
setReloadKey((key) => key + 1);
}}
>
Try again
</button>
</section>
)}
{loaded && (
<>
<section className="tool-controls" aria-label="Find a tool">
<label className="search-field">
<span className="visually-hidden">Search tools</span>
<span aria-hidden="true"></span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tools"
type="search"
/>
</label>
<label className="category-field">
<span className="visually-hidden">Filter by category</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{categories.map((item) => (
<option value={item} key={item}>
{item}
</option>
))}
</select>
</label>
{preferences.hidden.length > 0 && (
<label className="hidden-toggle">
<input
type="checkbox"
checked={showHidden}
onChange={(event) => setShowHidden(event.target.checked)}
/>
Show hidden ({preferences.hidden.length})
</label>
)}
</section>
{apps.length === 0 && loaded.unavailable.length === 0 && (
<section className="state-card">
<span className="state-icon" aria-hidden="true">
</span>
<h2>No tools yet</h2>
<p>
The catalogue is valid but does not contain any enabled
apps.
</p>
</section>
)}
{apps.length > 0 && visibleApps.length === 0 && (
<section className="state-card">
<span className="state-icon" aria-hidden="true">
</span>
<h2>No matching tools</h2>
<p>
Try another search or category, or show your hidden tools.
</p>
<button
type="button"
onClick={() => {
setQuery('');
setCategory('');
setShowHidden(true);
}}
>
Clear filters
</button>
</section>
)}
{visibleApps.length > 0 && (
<section className="app-grid" aria-label="Available tools">
{visibleApps.map((app) => {
const pinned = preferences.pinned.includes(app.id);
const peers = visibleApps.filter(
(peer) => preferences.pinned.includes(peer.id) === pinned
);
return (
<AppCard
key={app.id}
app={app}
catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl}
pinned={pinned}
hidden={preferences.hidden.includes(app.id)}
first={peers[0]?.id === app.id}
last={peers.at(-1)?.id === app.id}
onPin={() => toggleListItem('pinned', app.id)}
onHide={() => toggleListItem('hidden', app.id)}
onMove={(direction) => moveApp(app.id, direction)}
/>
);
})}
</section>
)}
{loaded.unavailable.length > 0 && (
<details className="manifest-errors">
<summary>
{loaded.unavailable.length} configured{' '}
{loaded.unavailable.length === 1 ? 'tool is' : 'tools are'}{' '}
unavailable
</summary>
<ul>
{loaded.unavailable.map((app) => (
<li key={app.id}>
<strong>{app.name}</strong>: {app.reason}
</li>
))}
</ul>
</details>
)}
</>
)}
</main>
<footer className="site-footer">
<p>
<Logo size={22} />{' '}
<span>
Privacy differs by tool. Review each disclosure before opening.
Apps open as full pagesnever embedded.
</span>
</p>
<span>
Portal v0.1.0 ·{' '}
<a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0"
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
</span>
</footer>
</div>
<PreferencesPanel
open={settingsOpen}
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
onClose={closeSettings}
/>
</>
);
}

102
src/catalogue.test.ts Normal file
View File

@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { contextualLaunchUrl, loadCatalogue } from './catalogue';
import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals());
describe('catalogue loading', () => {
it('loads and resolves an internal app using the shared contract', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const loaded = await loadCatalogue('/toolbox.catalog.json');
expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
expect(loaded.homeUrl).toBe('http://localhost:3000/');
expect(loaded.apps).toHaveLength(1);
expect(loaded.apps[0]).toMatchObject({
id: 'de.add-ideas.pdf-tools',
name: 'PDF Workbench',
entryUrl: 'http://localhost:3000/apps/pdf/',
sourceUrl: 'https://git.add-ideas.de/zemion/pdf-tools',
});
});
it('keeps valid apps available when another manifest fails', async () => {
const twoApps = {
...catalogue,
apps: [
...catalogue.apps,
{ manifest: './apps/missing/toolbox-app.json', enabled: true },
],
};
vi.stubGlobal(
'fetch',
vi.fn(
catalogueFetch({
'/toolbox.catalog.json': Response.json(twoApps),
})
)
);
const loaded = await loadCatalogue('/toolbox.catalog.json');
expect(loaded.apps.map((app) => app.id)).toEqual([
'de.add-ideas.pdf-tools',
]);
expect(loaded.unavailable).toHaveLength(1);
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
});
it('rejects cross-origin manifest references', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
catalogueFetch({
'/toolbox.catalog.json': Response.json({
...catalogue,
apps: [
{
manifest: 'https://example.invalid/toolbox-app.json',
enabled: true,
},
],
}),
})
)
);
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
/share the page origin/i
);
});
it('rejects a cross-origin catalogue home', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
catalogueFetch({
'/toolbox.catalog.json': Response.json({
...catalogue,
home: 'https://example.invalid/',
}),
})
)
);
await expect(loadCatalogue('/toolbox.catalog.json')).rejects.toThrow(
/share the page origin/i
);
});
it('adds catalogue context only to same-origin launch links', () => {
const contextual = new URL(
contextualLaunchUrl(
'http://localhost:3000/apps/pdf/',
'http://localhost:3000/toolbox.catalog.json'
)
);
expect(contextual.searchParams.get('toolbox')).toBe(
'http://localhost:3000/toolbox.catalog.json'
);
expect(
contextualLaunchUrl(
'https://example.com/tool',
'http://localhost:3000/toolbox.catalog.json'
)
).toBe('https://example.com/tool');
});
});

143
src/catalogue.ts Normal file
View File

@@ -0,0 +1,143 @@
import {
contextualizeToolboxLink,
fetchToolboxAppManifest,
fetchToolboxCatalog,
requireSameOrigin,
resolveToolboxApp,
resolveWebUrl,
type ToolboxCatalogEntry,
type ToolboxCatalogExternalEntry,
type ToolboxCatalogManifestEntry,
} from '@add-ideas/toolbox-contract';
import type { LoadedCatalogue, ResolvedApp, UnavailableApp } from './types';
function isInternal(
entry: ToolboxCatalogEntry
): entry is ToolboxCatalogManifestEntry {
return 'manifest' in entry;
}
function fallbackId(entry: ToolboxCatalogExternalEntry, entryUrl: URL): string {
const slug = entry.name
.toLocaleLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
let hash = 2166136261;
for (const character of `${entry.name}\0${entryUrl.href}`) {
hash ^= character.codePointAt(0) ?? 0;
hash = Math.imul(hash, 16777619);
}
return `external:${slug || 'tool'}:${(hash >>> 0).toString(36)}`;
}
export async function loadCatalogue(
catalogueHref: string,
signal?: AbortSignal
): Promise<LoadedCatalogue> {
const pageUrl = resolveWebUrl(document.baseURI);
const catalogueUrl = requireSameOrigin(
resolveWebUrl(catalogueHref, pageUrl),
pageUrl,
'Toolbox catalogue'
);
const catalogue = await fetchToolboxCatalog(catalogueUrl, {
signal,
expectedOrigin: pageUrl,
});
const enabled = catalogue.apps.filter((entry) => entry.enabled);
const results = await Promise.all(
enabled.map(
async (
entry
): Promise<{ app?: ResolvedApp; unavailable?: UnavailableApp }> => {
if (!isInternal(entry)) {
const entryUrl = resolveWebUrl(entry.entry, catalogueUrl);
return {
app: {
id: fallbackId(entry, entryUrl),
name: entry.name,
description: 'External tool',
entryUrl: entryUrl.href,
categories: [],
tags: [],
launchModes: [entry.launch],
isExternal: true,
},
};
}
const manifestUrl = requireSameOrigin(
resolveWebUrl(entry.manifest, catalogueUrl),
pageUrl,
'Application manifest'
);
try {
const manifest = await fetchToolboxAppManifest(manifestUrl, {
signal,
expectedOrigin: pageUrl,
});
const resolved = resolveToolboxApp(manifest, manifestUrl, pageUrl);
return {
app: {
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
entryUrl: resolved.entryUrl.href,
iconUrl: resolved.iconUrl.href,
categories: manifest.categories,
tags: manifest.tags,
launchModes: manifest.integration.launchModes,
isExternal: false,
manifestUrl: resolved.manifestUrl.href,
sourceUrl: manifest.source?.repository,
requirements: manifest.requirements,
privacy: manifest.privacy,
},
};
} catch (error) {
if (signal?.aborted) throw error;
return {
unavailable: {
id: `unavailable:${manifestUrl.href}`,
name: manifestUrl.pathname.split('/').at(-2) ?? 'Unknown app',
manifestUrl: manifestUrl.href,
reason:
error instanceof Error
? error.message
: 'Unknown manifest error.',
},
};
}
}
)
);
const apps = results.flatMap((result) => (result.app ? [result.app] : []));
const ids = new Set<string>();
for (const app of apps) {
if (ids.has(app.id)) throw new Error(`Duplicate app id: ${app.id}.`);
ids.add(app.id);
}
return {
catalogue,
catalogueUrl,
homeUrl: requireSameOrigin(
resolveWebUrl(catalogue.home, catalogueUrl),
pageUrl,
'Toolbox home'
).href,
apps,
unavailable: results.flatMap((result) =>
result.unavailable ? [result.unavailable] : []
),
};
}
export function contextualLaunchUrl(
entryHref: string,
catalogueHref: string
): string {
return contextualizeToolboxLink(entryHref, catalogueHref);
}

172
src/components/AppCard.tsx Normal file
View File

@@ -0,0 +1,172 @@
import type { ResolvedApp } from '../types';
interface AppCardProps {
app: ResolvedApp;
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
first: boolean;
last: boolean;
onHide: () => void;
onMove: (direction: -1 | 1) => void;
onPin: () => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
}
function RequirementSummary({ app }: { app: ResolvedApp }) {
if (!app.requirements) return null;
const requirements = [
app.requirements.secureContext && 'Secure context',
app.requirements.workers && 'Web workers',
app.requirements.indexedDb && 'IndexedDB',
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
].filter(Boolean);
if (requirements.length === 0) return null;
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
}
function PrivacySummary({ app }: { app: ResolvedApp }) {
if (!app.privacy)
return (
<p className="privacy-summary">
External destination · review its privacy terms
</p>
);
if (app.privacy.label)
return <p className="privacy-summary">{app.privacy.label}</p>;
const processing = {
local: 'Local processing',
remote: 'Remote processing',
mixed: 'Local and remote capabilities',
}[app.privacy.processing];
return (
<p className="privacy-summary">
{processing} ·{' '}
{app.privacy.fileUploads
? 'file/data transmission declared'
: 'no file uploads declared'}{' '}
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
</p>
);
}
export function AppCard({
app,
catalogueUrl,
hidden,
pinned,
first,
last,
onHide,
onMove,
onPin,
contextualUrl,
}: AppCardProps) {
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<article
className={`app-card${hidden ? ' app-card--hidden' : ''}`}
data-testid={`app-card-${app.id}`}
>
<div className="app-card__top">
<div className="app-card__icon" aria-hidden="true">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" loading="lazy" />
) : (
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<div
className="app-card__actions"
aria-label={`Personalize ${app.name}`}
>
<button
className={`icon-button${pinned ? ' is-active' : ''}`}
type="button"
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
aria-pressed={pinned}
title={pinned ? 'Unpin' : 'Pin'}
onClick={onPin}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={`Move ${app.name} earlier`}
disabled={first}
onClick={() => onMove(-1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={`Move ${app.name} later`}
disabled={last}
onClick={() => onMove(1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
onClick={onHide}
>
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
</button>
</div>
</div>
<div className="app-card__body">
<div className="title-line">
<h2>{app.name}</h2>
{pinned && <span className="status-chip">Pinned</span>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
)}
{app.isExternal && (
<span className="status-chip status-chip--muted">External</span>
)}
</div>
<p>{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
<div className="tag-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<span key={category}>{category}</span>
))}
</div>
</div>
<footer className="app-card__footer">
<div className="app-card__meta">
<span className="version">
{app.version ? `v${app.version}` : 'External service'}
</span>
{app.sourceUrl && (
<a
className="source-link"
href={app.sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
)}
</div>
<a
className="launch-button"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{target ? 'Open in new tab' : 'Open tool'}{' '}
<span aria-hidden="true"></span>
</a>
</footer>
</article>
);
}

36
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,36 @@
interface LogoProps {
size?: number;
}
export function Logo({ size = 38 }: LogoProps) {
return (
<svg
aria-hidden="true"
className="logo"
height={size}
viewBox="0 0 48 48"
width={size}
>
<path
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
fill="currentColor"
opacity=".12"
/>
<path
d="m8 15 16 9 16-9M24 24v18"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
/>
<path
d="m16 10.5 16 9v9L24 33l-8-4.5z"
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="3"
/>
</svg>
);
}

View File

@@ -0,0 +1,237 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import {
defaultPreferences,
parsePreferences,
type Preferences,
} from '../preferences';
import type { ThemeMode } from '../types';
interface PreferencesPanelProps {
open: boolean;
preferences: Preferences;
storageAvailable: boolean;
onChange: (preferences: Preferences) => void;
onClose: () => void;
}
export function PreferencesPanel({
open,
preferences,
storageAvailable,
onChange,
onClose,
}: PreferencesPanelProps) {
const fileInput = useRef<HTMLInputElement>(null);
const panel = useRef<HTMLElement>(null);
const [message, setMessage] = useState('');
useEffect(() => {
if (!open) return;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusableElements = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => !element.hasAttribute('disabled')
)
: [];
focusableElements()[0]?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) {
event.preventDefault();
dialog?.focus();
return;
}
const first = elements[0]!;
const last = elements.at(-1)!;
const activeElement = document.activeElement;
if (
event.shiftKey &&
(activeElement === first || !dialog?.contains(activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(activeElement === last || !dialog?.contains(activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
returnFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
function setTheme(theme: ThemeMode) {
onChange({ ...preferences, theme });
}
function exportPreferences() {
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'toolbox-preferences.json';
anchor.click();
URL.revokeObjectURL(url);
setMessage('Preferences exported.');
}
async function importPreferences(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const imported = parsePreferences(JSON.parse(await file.text()));
onChange(imported);
setMessage('Preferences imported.');
} catch (error) {
setMessage(
error instanceof Error
? `Import failed: ${error.message}`
: 'Import failed.'
);
}
}
return (
<div
className="dialog-backdrop"
role="presentation"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<section
ref={panel}
className="preferences-panel"
role="dialog"
aria-modal="true"
aria-labelledby="preferences-title"
tabIndex={-1}
>
<header>
<div>
<p className="eyebrow">On this device</p>
<h2 id="preferences-title">Personalize your toolbox</h2>
</div>
<button
className="close-button"
type="button"
aria-label="Close preferences"
onClick={onClose}
>
×
</button>
</header>
{!storageAvailable && (
<p className="inline-warning" role="status">
Browser storage is unavailable. Changes will last only until this
page closes.
</p>
)}
<fieldset className="theme-choice">
<legend>Appearance</legend>
<div>
{(['system', 'light', 'dark'] as const).map((theme) => (
<button
key={theme}
type="button"
aria-pressed={preferences.theme === theme}
className={preferences.theme === theme ? 'is-selected' : ''}
onClick={() => setTheme(theme)}
>
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
</button>
))}
</div>
</fieldset>
<div className="preference-summary">
<div>
<strong>{preferences.pinned.length}</strong>
<span>Pinned</span>
</div>
<div>
<strong>{preferences.hidden.length}</strong>
<span>Hidden</span>
</div>
<div>
<strong>{preferences.order.length}</strong>
<span>Ordered</span>
</div>
</div>
<p className="preferences-copy">
The portal stores pins, visibility, order, and appearance in this
browser and does not transmit them. Trusted code in another Toolbox
app on this site can access same-origin storage. Export a JSON backup
to move them to another device.
</p>
<div className="preferences-actions">
<button type="button" onClick={exportPreferences}>
Export JSON
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Import JSON
</button>
<input
ref={fileInput}
className="visually-hidden"
type="file"
tabIndex={-1}
accept="application/json,.json"
onChange={importPreferences}
/>
<button
className="danger-button"
type="button"
onClick={() => {
onChange({ ...defaultPreferences });
setMessage('Preferences reset.');
}}
>
Reset
</button>
</div>
<p className="action-message" role="status" aria-live="polite">
{message}
</p>
</section>
</div>
);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

78
src/preferences.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
PREFERENCES_KEY,
cleanPreferences,
defaultPreferences,
moveId,
parsePreferences,
readPreferences,
sortAppIds,
writePreferences,
} from './preferences';
describe('preferences', () => {
it('round-trips namespaced settings', () => {
const preferences = {
...defaultPreferences,
pinned: ['app.two'],
order: ['app.two', 'app.one'],
hidden: ['app.one'],
theme: 'dark' as const,
};
writePreferences(preferences);
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('app.two');
expect(readPreferences()).toEqual(preferences);
});
it('falls back safely for corrupt stored JSON', () => {
localStorage.setItem(PREFERENCES_KEY, '{nope');
expect(readPreferences()).toEqual(defaultPreferences);
});
it('validates imported JSON and removes duplicate ids', () => {
expect(
parsePreferences({
version: 1,
pinned: ['app.one', 'app.one'],
order: [],
hidden: [],
theme: 'system',
}).pinned
).toEqual(['app.one']);
expect(() => parsePreferences({ version: 2 })).toThrow(/version/i);
expect(() =>
parsePreferences({
version: 1,
pinned: 'app.one',
order: [],
hidden: [],
theme: 'system',
})
).toThrow(/pinned/i);
});
it('cleans stale ids, pins first, and supports personal ordering', () => {
const cleaned = cleanPreferences(
{
version: 1,
pinned: ['app.three', 'missing'],
hidden: ['missing'],
order: ['app.two', 'missing'],
theme: 'system',
},
['app.one', 'app.two', 'app.three']
);
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
expect(cleaned.hidden).toEqual([]);
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
'app.three',
'app.two',
'app.one',
]);
expect(moveId(cleaned.order, 'app.one', -1)).toEqual([
'app.one',
'app.two',
'app.three',
]);
});
});

110
src/preferences.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { ThemeMode } from './types';
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences';
export interface Preferences {
version: 1;
pinned: string[];
order: string[];
hidden: string[];
theme: ThemeMode;
}
export const defaultPreferences: Preferences = {
version: 1,
pinned: [],
order: [],
hidden: [],
theme: 'system',
};
function uniqueStrings(value: unknown, field: string): string[] {
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== 'string' || item.length === 0)
)
throw new Error(`${field} must be an array of non-empty strings.`);
return [...new Set(value)];
}
export function parsePreferences(value: unknown): Preferences {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Preferences must be a JSON object.');
}
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1)
throw new Error('Unsupported preferences version.');
const theme = candidate.theme;
if (!['light', 'dark', 'system'].includes(String(theme)))
throw new Error('Invalid theme preference.');
return {
version: 1,
pinned: uniqueStrings(candidate.pinned, 'pinned'),
order: uniqueStrings(candidate.order, 'order'),
hidden: uniqueStrings(candidate.hidden, 'hidden'),
theme: theme as ThemeMode,
};
}
export function readPreferences(storage: Storage = localStorage): Preferences {
const value = storage.getItem(PREFERENCES_KEY);
if (!value) return { ...defaultPreferences };
try {
return parsePreferences(JSON.parse(value));
} catch {
return { ...defaultPreferences };
}
}
export function writePreferences(
preferences: Preferences,
storage: Storage = localStorage
): void {
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
}
export function cleanPreferences(
preferences: Preferences,
appIds: string[]
): Preferences {
const known = new Set(appIds);
return {
...preferences,
pinned: preferences.pinned.filter((id) => known.has(id)),
hidden: preferences.hidden.filter((id) => known.has(id)),
order: [
...preferences.order.filter((id) => known.has(id)),
...appIds.filter((id) => !preferences.order.includes(id)),
],
};
}
export function sortAppIds(
appIds: string[],
preferences: Preferences
): string[] {
const order = new Map(preferences.order.map((id, index) => [id, index]));
const pinned = new Set(preferences.pinned);
return [...appIds].sort((left, right) => {
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
if (pinDifference) return pinDifference;
return (
(order.get(left) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right) ?? Number.MAX_SAFE_INTEGER)
);
});
}
export function moveId(
order: string[],
id: string,
direction: -1 | 1
): string[] {
const current = order.indexOf(id);
if (current < 0) return order;
const target = current + direction;
if (target < 0 || target >= order.length) return order;
const next = [...order];
[next[current], next[target]] = [next[target]!, next[current]!];
return next;
}

814
src/styles.css Normal file
View File

@@ -0,0 +1,814 @@
:root {
color-scheme: light dark;
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, system-ui, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #f6f7fb;
--surface: #ffffff;
--surface-soft: #eff2f8;
--text: #17203b;
--muted: #667085;
--line: #dfe3ec;
--brand: #28366d;
--brand-hover: #1c2858;
--brand-soft: #e9edff;
--accent: #78d8ca;
--danger: #a63243;
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
}
:root[data-theme='dark'] {
color-scheme: dark;
--bg: #0f1422;
--surface: #171d2d;
--surface-soft: #20283a;
--text: #edf1fb;
--muted: #a9b3ca;
--line: #303a50;
--brand: #a9b7ff;
--brand-hover: #c3ccff;
--brand-soft: #252f55;
--accent: #66cdbd;
--danger: #ff9dac;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
@media (prefers-color-scheme: dark) {
:root[data-theme='system'] {
color-scheme: dark;
--bg: #0f1422;
--surface: #171d2d;
--surface-soft: #20283a;
--text: #edf1fb;
--muted: #a9b3ca;
--line: #303a50;
--brand: #a9b7ff;
--brand-hover: #c3ccff;
--brand-soft: #252f55;
--accent: #66cdbd;
--danger: #ff9dac;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
}
* {
box-sizing: border-box;
}
html {
background: var(--bg);
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
radial-gradient(
circle at 8% 4%,
color-mix(in srgb, var(--accent) 13%, transparent) 0,
transparent 28rem
),
var(--bg);
color: var(--text);
}
button,
input,
select {
color: inherit;
font: inherit;
}
button,
a,
input,
select {
-webkit-tap-highlight-color: transparent;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 70%, var(--brand));
outline-offset: 3px;
}
.site-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.skip-link {
position: fixed;
z-index: 100;
top: 0.75rem;
left: 0.75rem;
padding: 0.7rem 1rem;
border-radius: 0.55rem;
background: var(--surface);
color: var(--text);
transform: translateY(-160%);
}
.skip-link:focus {
transform: translateY(0);
}
.site-header {
position: sticky;
z-index: 20;
top: 0;
border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(16px);
}
.header-inner,
main,
.site-footer {
width: min(1160px, calc(100% - 2.5rem));
margin-inline: auto;
}
.header-inner {
min-height: 74px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: var(--text);
text-decoration: none;
}
.brand .logo {
color: var(--brand);
}
.brand > span {
display: grid;
line-height: 1.05;
}
.brand strong {
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
font-size: 1.03rem;
letter-spacing: -0.025em;
}
.brand small {
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.17em;
text-transform: uppercase;
}
.settings-button,
.state-card button,
.preferences-actions button {
border: 1px solid var(--line);
border-radius: 0.65rem;
background: var(--surface);
cursor: pointer;
font-weight: 600;
}
.settings-button {
padding: 0.62rem 0.82rem;
}
.settings-button:hover,
.state-card button:hover,
.preferences-actions button:hover {
border-color: var(--brand);
color: var(--brand);
}
main {
flex: 1;
}
.hero {
max-width: 750px;
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem);
}
.eyebrow {
margin: 0 0 1rem;
color: var(--brand);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
font-size: clamp(2.65rem, 7vw, 5.65rem);
line-height: 0.98;
letter-spacing: -0.065em;
}
.hero h1 span {
color: var(--brand);
}
.hero-copy {
max-width: 575px;
margin: 1.75rem 0 0;
color: var(--muted);
font-size: clamp(1rem, 2vw, 1.18rem);
line-height: 1.7;
}
.tool-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.search-field,
.category-field {
display: flex;
min-height: 46px;
align-items: center;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--surface);
}
.search-field {
width: min(360px, 100%);
padding: 0 0.85rem;
gap: 0.55rem;
}
.search-field > span {
color: var(--muted);
font-size: 1.35rem;
}
.search-field input,
.category-field select {
width: 100%;
border: 0;
outline: 0;
background: transparent;
}
.search-field input::placeholder {
color: var(--muted);
}
.category-field {
min-width: 175px;
padding: 0 0.65rem;
}
.category-field select {
cursor: pointer;
}
.hidden-toggle {
display: flex;
align-items: center;
gap: 0.45rem;
margin-left: auto;
color: var(--muted);
cursor: pointer;
font-size: 0.88rem;
}
.hidden-toggle input {
width: 1.05rem;
height: 1.05rem;
accent-color: var(--brand);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr));
gap: 1.1rem;
padding-bottom: 2rem;
}
.app-card {
min-height: 340px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 1.2rem;
background: var(--surface);
box-shadow: var(--shadow);
transition:
border-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
}
.app-card:hover {
border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
transform: translateY(-2px);
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
}
.app-card--hidden {
border-style: dashed;
opacity: 0.7;
}
.app-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.35rem 1.35rem 0;
}
.app-card__icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 0.95rem;
background: var(--brand-soft);
color: var(--brand);
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.45rem;
font-weight: 700;
}
.app-card__icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.app-card__actions {
display: flex;
gap: 0.15rem;
}
.icon-button,
.close-button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.icon-button:hover:not(:disabled),
.icon-button.is-active {
background: var(--brand-soft);
color: var(--brand);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.28;
}
.app-card__body {
flex: 1;
padding: 1.25rem 1.35rem 1rem;
}
.title-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
}
.title-line h2 {
margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.3rem;
letter-spacing: -0.025em;
}
.status-chip,
.tag-row span {
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand);
font-size: 0.68rem;
font-weight: 700;
}
.status-chip {
padding: 0.22rem 0.48rem;
text-transform: uppercase;
}
.status-chip--muted {
background: var(--surface-soft);
color: var(--muted);
}
.app-card__body > p {
margin: 0.75rem 0 0;
color: var(--muted);
line-height: 1.6;
}
.app-card__body .requirements {
font-size: 0.72rem;
}
.app-card__body .privacy-summary {
color: var(--text);
font-size: 0.76rem;
font-weight: 650;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1rem;
}
.tag-row span {
padding: 0.28rem 0.52rem;
background: var(--surface-soft);
color: var(--muted);
}
.app-card__footer {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.35rem;
border-top: 1px solid var(--line);
}
.version {
color: var(--muted);
font-size: 0.78rem;
}
.app-card__meta {
display: grid;
gap: 0.25rem;
}
.source-link,
.site-footer a {
color: var(--brand);
}
.launch-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.62rem 0.82rem;
border-radius: 0.65rem;
background: var(--brand);
color: var(--surface);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
:root[data-theme='dark'] .launch-button {
color: #11182a;
}
.launch-button:hover {
background: var(--brand-hover);
}
.state-card {
max-width: 600px;
margin: 0 auto 5rem;
padding: 3rem 2rem;
border: 1px solid var(--line);
border-radius: 1.2rem;
background: var(--surface);
text-align: center;
box-shadow: var(--shadow);
}
.state-card h2 {
margin: 0.8rem 0 0.35rem;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
}
.state-card p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.state-card button {
margin-top: 1.25rem;
padding: 0.7rem 1rem;
}
.state-icon {
width: 46px;
height: 46px;
display: inline-grid;
place-items: center;
border-radius: 50%;
background: var(--brand-soft);
color: var(--brand);
font-size: 1.25rem;
font-weight: 700;
}
.state-card--error .state-icon {
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
}
.loader {
width: 38px;
height: 38px;
margin: 0 auto;
border: 3px solid var(--line);
border-top-color: var(--brand);
border-radius: 50%;
animation: spin 700ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.manifest-errors {
margin: 1.5rem 0 4rem;
padding: 1rem 1.2rem;
border: 1px solid color-mix(in srgb, var(--danger) 30%, var(--line));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--danger) 5%, var(--surface));
color: var(--muted);
}
.manifest-errors summary {
cursor: pointer;
color: var(--text);
font-weight: 600;
}
.manifest-errors li + li {
margin-top: 0.5rem;
}
.site-footer {
min-height: 110px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 5rem;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.8rem;
}
.site-footer p {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dialog-backdrop {
position: fixed;
z-index: 50;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(5, 9, 22, 0.56);
backdrop-filter: blur(5px);
}
.preferences-panel {
width: min(560px, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
padding: 1.5rem;
border: 1px solid var(--line);
border-radius: 1.2rem;
background: var(--surface);
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
}
.preferences-panel > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.preferences-panel h2 {
margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
letter-spacing: -0.025em;
}
.preferences-panel .eyebrow {
margin-bottom: 0.5rem;
}
.close-button {
flex: 0 0 auto;
background: var(--surface-soft);
color: var(--text);
font-size: 1.3rem;
}
.theme-choice {
margin: 1.5rem 0;
padding: 0;
border: 0;
}
.theme-choice legend {
margin-bottom: 0.6rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.theme-choice > div {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.45rem;
padding: 0.35rem;
border-radius: 0.75rem;
background: var(--surface-soft);
}
.theme-choice button {
padding: 0.62rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
cursor: pointer;
}
.theme-choice button.is-selected {
background: var(--surface);
color: var(--brand);
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
font-weight: 700;
}
.preference-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.6rem;
}
.preference-summary > div {
display: grid;
gap: 0.2rem;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
text-align: center;
}
.preference-summary strong {
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.25rem;
}
.preference-summary span,
.preferences-copy,
.action-message {
color: var(--muted);
font-size: 0.8rem;
}
.preferences-copy {
margin: 1.1rem 0;
line-height: 1.55;
}
.preferences-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.preferences-actions button {
padding: 0.62rem 0.78rem;
}
.preferences-actions .danger-button {
margin-left: auto;
color: var(--danger);
}
.action-message {
min-height: 1.2em;
margin: 0.8rem 0 0;
}
.inline-warning {
padding: 0.7rem 0.8rem;
border-radius: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger);
font-size: 0.82rem;
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
@media (max-width: 700px) {
.header-inner,
main,
.site-footer {
width: min(100% - 1.4rem, 1160px);
}
.header-inner {
min-height: 66px;
}
.settings-button {
font-size: 0;
}
.settings-button span {
font-size: 1.15rem;
}
.hero {
padding-top: 3.8rem;
}
.tool-controls {
align-items: stretch;
flex-direction: column;
}
.search-field,
.category-field {
width: 100%;
}
.hidden-toggle {
margin: 0.25rem 0;
}
.app-card {
min-height: 320px;
}
.site-footer {
min-height: 130px;
align-items: flex-start;
flex-direction: column;
justify-content: center;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

61
src/test/fixtures.ts Normal file
View File

@@ -0,0 +1,61 @@
import type {
ToolboxAppManifest,
ToolboxCatalog,
} from '@add-ideas/toolbox-contract';
export const pdfManifest: ToolboxAppManifest = {
schemaVersion: 1,
id: 'de.add-ideas.pdf-tools',
name: 'PDF Workbench',
version: '0.3.4',
description: 'Page-level PDF operations performed locally in the browser.',
entry: './',
icon: './favicon.svg',
categories: ['documents', 'pdf'],
tags: ['merge', 'split'],
integration: {
contextVersion: 1,
launchModes: ['navigate', 'new-tab'],
embedding: 'unsupported',
},
requirements: {
secureContext: true,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
},
privacy: {
processing: 'local',
fileUploads: false,
telemetry: false,
},
source: {
repository: 'https://git.add-ideas.de/zemion/pdf-tools',
license: 'AGPL-3.0-only',
},
};
export const catalogue: ToolboxCatalog = {
schemaVersion: 1,
id: 'de.add-ideas.toolbox',
name: 'add·ideas Toolbox',
home: './',
theme: { mode: 'system', brand: 'add·ideas' },
apps: [{ manifest: './apps/pdf/toolbox-app.json', enabled: true }],
};
export function catalogueFetch(overrides: Record<string, Response> = {}) {
return async (input: string | URL | Request): Promise<Response> => {
const url = new URL(
input instanceof Request ? input.url : String(input),
document.baseURI
);
const custom = overrides[url.pathname];
if (custom) return custom.clone();
if (url.pathname.endsWith('/toolbox.catalog.json'))
return Response.json(catalogue);
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
return Response.json(pdfManifest);
return new Response('Not found', { status: 404 });
};
}

10
src/test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
if (typeof localStorage !== 'undefined') localStorage.clear();
if (typeof document !== 'undefined')
document.documentElement.removeAttribute('data-theme');
});

39
src/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import type {
ToolboxAppManifest,
ToolboxCatalog,
ToolboxRequirements,
} from '@add-ideas/toolbox-contract';
export type ThemeMode = ToolboxCatalog['theme']['mode'];
export interface ResolvedApp {
id: string;
name: string;
version?: string;
description: string;
entryUrl: string;
iconUrl?: string;
categories: readonly string[];
tags: readonly string[];
launchModes: ToolboxAppManifest['integration']['launchModes'];
isExternal: boolean;
manifestUrl?: string;
sourceUrl?: string;
requirements?: ToolboxRequirements;
privacy?: ToolboxAppManifest['privacy'];
}
export interface UnavailableApp {
id: string;
name: string;
manifestUrl: string;
reason: string;
}
export interface LoadedCatalogue {
catalogue: ToolboxCatalog;
catalogueUrl: URL;
homeUrl: string;
apps: ResolvedApp[];
unavailable: UnavailableApp[];
}

47
src/usePreferences.ts Normal file
View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import {
PREFERENCES_KEY,
defaultPreferences,
readPreferences,
writePreferences,
type Preferences,
} from './preferences';
interface InitialPreferences {
preferences: Preferences;
storageAvailable: boolean;
}
function initializePreferences(): InitialPreferences {
try {
const preferences = readPreferences();
const probeKey = `${PREFERENCES_KEY}:probe`;
localStorage.setItem(probeKey, '1');
localStorage.removeItem(probeKey);
return { preferences, storageAvailable: true };
} catch {
return { preferences: { ...defaultPreferences }, storageAvailable: false };
}
}
export function usePreferences(): [
Preferences,
(next: Preferences | ((current: Preferences) => Preferences)) => void,
boolean,
] {
const [initial] = useState(initializePreferences);
const [preferences, setPreferences] = useState<Preferences>(
initial.preferences
);
useEffect(() => {
try {
writePreferences(preferences);
} catch {
// The UI already reports storage that was unavailable during initialization.
// A later quota/privacy-mode failure leaves this session's in-memory choices intact.
}
}, [preferences]);
return [preferences, setPreferences, initial.storageAvailable];
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />