Release Device Tools v0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Device Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Device Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Device Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
The passive inventory checks browser API presence, viewport and input
|
||||
hints, declared codec support and media constraints without requesting
|
||||
permissions or enumerating hardware.
|
||||
</p>
|
||||
<p>
|
||||
Optional probes run only from their individual buttons. Camera,
|
||||
microphone, screen and persistence checks can prompt; returned media
|
||||
tracks are stopped immediately and no content is recorded.
|
||||
</p>
|
||||
<p>
|
||||
Reports contain no timestamp, user agent, locale, identifier, hash or
|
||||
uniqueness score. Exact high-entropy measurements are bucketed before
|
||||
JSON or CSV export. Nothing is uploaded.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
collectPassiveCapabilities,
|
||||
createRedactedReport,
|
||||
exportReportCsv,
|
||||
exportReportJson,
|
||||
PROBES,
|
||||
runProbe,
|
||||
type Capability,
|
||||
type ProbeId,
|
||||
type ProbeResult,
|
||||
} from "../core/device";
|
||||
|
||||
export function Workbench() {
|
||||
const [capabilities, setCapabilities] = useState<Capability[]>(() =>
|
||||
collectPassiveCapabilities(),
|
||||
);
|
||||
const [results, setResults] = useState<Partial<Record<ProbeId, ProbeResult>>>(
|
||||
{},
|
||||
);
|
||||
const [running, setRunning] = useState<ProbeId | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [status, setStatus] = useState(
|
||||
"Passive API presence was checked locally. No permission was requested.",
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
return needle === ""
|
||||
? capabilities
|
||||
: capabilities.filter((item) =>
|
||||
(item.label + " " + item.group + " " + item.value)
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
);
|
||||
}, [capabilities, filter]);
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
[...new Set(filtered.map((item) => item.group))].map((group) => ({
|
||||
group,
|
||||
items: filtered.filter((item) => item.group === group),
|
||||
})),
|
||||
[filtered],
|
||||
);
|
||||
const completed = PROBES.flatMap((probe) => {
|
||||
const result = results[probe.id];
|
||||
return result ? [result] : [];
|
||||
});
|
||||
const report = createRedactedReport(capabilities, completed);
|
||||
const available = capabilities.filter(
|
||||
(item) => item.state === "available",
|
||||
).length;
|
||||
|
||||
async function performProbe(id: ProbeId): Promise<void> {
|
||||
setRunning(id);
|
||||
setStatus("Running the selected probe locally…");
|
||||
const result = await runProbe(id);
|
||||
setResults((current) => ({ ...current, [id]: result }));
|
||||
setRunning(null);
|
||||
setStatus(result.summary);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Browser capability and privacy lab</p>
|
||||
<h1>Inspect capabilities, not identity.</h1>
|
||||
<p>
|
||||
See what this browser exposes, run sensitive probes only when you
|
||||
choose, and export a deliberately redacted report without a device
|
||||
fingerprint or score.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">No exfiltration</span>
|
||||
</section>
|
||||
|
||||
<section className="principles" aria-labelledby="principles-title">
|
||||
<div>
|
||||
<p className="eyebrow">Privacy contract</p>
|
||||
<h2 id="principles-title">Useful evidence with a hard boundary</h2>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
No user agent, locale, device name, stable identifier or hash.
|
||||
</li>
|
||||
<li>No canvas/audio fingerprint sample and no uniqueness score.</li>
|
||||
<li>
|
||||
No API request leaves this origin; media tracks stop immediately.
|
||||
</li>
|
||||
<li>Downloaded reports bucket exact high-entropy measurements.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="panel" aria-labelledby="inventory-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Passive inventory</p>
|
||||
<h2 id="inventory-title">Browser and display capabilities</h2>
|
||||
</div>
|
||||
<span className="count-pill">
|
||||
{available} of {capabilities.length} available
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
These checks read API presence, media queries, viewport values,
|
||||
supported constraints and codec declarations. They do not request
|
||||
permission or enumerate hardware.
|
||||
</p>
|
||||
<div className="inventory-toolbar">
|
||||
<label>
|
||||
Filter capabilities
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="display, media, storage…"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCapabilities(collectPassiveCapabilities());
|
||||
setStatus(
|
||||
"Passive inventory refreshed; no permission was requested.",
|
||||
);
|
||||
}}
|
||||
>
|
||||
Refresh passive inventory
|
||||
</button>
|
||||
</div>
|
||||
<div className="capability-groups">
|
||||
{groups.map(({ group, items }) => (
|
||||
<section key={group} className="capability-group">
|
||||
<h3>{group}</h3>
|
||||
<div className="capability-table" tabIndex={0}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>State</th>
|
||||
<th>Local value</th>
|
||||
<th>Privacy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} title={item.explanation}>
|
||||
<th scope="row">{item.label}</th>
|
||||
<td>
|
||||
<State state={item.state} />
|
||||
</td>
|
||||
<td>{item.value}</td>
|
||||
<td>
|
||||
<Risk value={item.privacy} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{groups.length === 0 && (
|
||||
<p className="empty-state">No capability matches this filter.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" aria-labelledby="probes-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Explicit user-gated checks</p>
|
||||
<h2 id="probes-title">Optional probes</h2>
|
||||
</div>
|
||||
<span className="count-pill">{completed.length} run</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Nothing below runs automatically. Buttons marked “may prompt” can
|
||||
trigger browser or operating-system UI.
|
||||
</p>
|
||||
<div className="probe-grid">
|
||||
{PROBES.map((probe) => {
|
||||
const result = results[probe.id];
|
||||
return (
|
||||
<article className="probe-card" key={probe.id}>
|
||||
<div className="probe-heading">
|
||||
<h3>{probe.title}</h3>
|
||||
<Risk value={probe.privacy} />
|
||||
</div>
|
||||
<p>{probe.description}</p>
|
||||
{probe.prompts && (
|
||||
<strong className="prompt-note">May prompt</strong>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={running !== null}
|
||||
onClick={() => void performProbe(probe.id)}
|
||||
>
|
||||
{running === probe.id ? "Running…" : probe.action}
|
||||
</button>
|
||||
{result && <ProbeOutcome result={result} />}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="status" role="status" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel report-panel" aria-labelledby="report-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Shareable output</p>
|
||||
<h2 id="report-title">Redacted capability report</h2>
|
||||
</div>
|
||||
<span className="count-pill">No timestamp or identifier</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
The preview is the exact downloaded payload. Screen/viewport/DPR,
|
||||
storage, GPU limits, device counts, battery timing and network quality
|
||||
are reduced to broad buckets.
|
||||
</p>
|
||||
<pre tabIndex={0} aria-label="Redacted JSON report">
|
||||
{exportReportJson(report)}
|
||||
</pre>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportReportJson(report)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
"device-capabilities-redacted.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download redacted JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportReportCsv(report)], { type: "text/csv" }),
|
||||
"device-capabilities-redacted.csv",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download redacted CSV
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ProbeOutcome({ result }: { result: ProbeResult }) {
|
||||
return (
|
||||
<div className={"probe-result " + result.state}>
|
||||
<strong>{result.state}</strong>
|
||||
<p>{result.summary}</p>
|
||||
{result.details.length > 0 && (
|
||||
<dl>
|
||||
{result.details.map((detail) => (
|
||||
<div key={detail.label}>
|
||||
<dt>{detail.label}</dt>
|
||||
<dd>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function State({ state }: { state: Capability["state"] }) {
|
||||
return <span className={"state-chip " + state}>{state}</span>;
|
||||
}
|
||||
|
||||
function Risk({ value }: { value: Capability["privacy"] }) {
|
||||
return <span className={"risk-chip " + value}>{value}</span>;
|
||||
}
|
||||
|
||||
function download(blob: Blob, name: string): void {
|
||||
triggerBlobDownload(blob, name);
|
||||
}
|
||||
+1205
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
:root {
|
||||
--toolbox-background: #f5f7f8;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #edf2f3;
|
||||
--toolbox-text: #18272d;
|
||||
--toolbox-muted: #617078;
|
||||
--toolbox-border: #d5dfe2;
|
||||
--toolbox-accent: #136d72;
|
||||
--toolbox-accent-hover: #0d585d;
|
||||
--toolbox-accent-soft: #ddf1f1;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #8055c7;
|
||||
--toolbox-danger: #ad2948;
|
||||
--device-warning: #9c610a;
|
||||
--device-success: #13735c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--toolbox-focus) 35%, transparent);
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.workbench :where(h1, h2, h3),
|
||||
.help-dialog :where(h2, h3),
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.027em;
|
||||
line-height: 1.16;
|
||||
}
|
||||
.workbench h1 {
|
||||
font-size: clamp(1.75rem, 3vw, 2.55rem);
|
||||
}
|
||||
.workbench h2,
|
||||
.help-dialog h2 {
|
||||
font-size: clamp(1.18rem, 2vw, 1.5rem);
|
||||
}
|
||||
.workbench h3 {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
.workbench :where(p, ul, dl) {
|
||||
margin-block: 0;
|
||||
}
|
||||
.workbench :where(button, input),
|
||||
.help-dialog button {
|
||||
font: inherit;
|
||||
}
|
||||
.workbench button,
|
||||
.help-dialog button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.64rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.workbench button:hover:not(:disabled) {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.workbench button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.workbench .primary-button {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.workbench .primary-button:hover:not(:disabled) {
|
||||
border-color: var(--toolbox-accent-hover);
|
||||
background: var(--toolbox-accent-hover);
|
||||
}
|
||||
:where(.workbench, .help-dialog) :where(button, input):focus-visible,
|
||||
.capability-table:focus-visible,
|
||||
.report-panel pre:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.workbench input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.workbench label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.77rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
.hero,
|
||||
.panel,
|
||||
.principles {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.82rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgb(24 31 65 / 4%),
|
||||
0 10px 30px rgb(24 31 65 / 3%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.55rem;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem !important;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill,
|
||||
.count-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.64rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
.principles {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 0.65fr) minmax(18rem, 1.35fr);
|
||||
gap: 1.2rem;
|
||||
padding: clamp(0.9rem, 2vw, 1.2rem);
|
||||
border-left: 0.28rem solid var(--toolbox-accent);
|
||||
}
|
||||
.principles ul {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.panel {
|
||||
padding: clamp(0.9rem, 2vw, 1.2rem);
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.muted,
|
||||
.status {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.status {
|
||||
min-height: 1.3rem;
|
||||
margin-top: 0.8rem !important;
|
||||
}
|
||||
.inventory-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.capability-groups {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.capability-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.capability-group h3 {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.capability-table {
|
||||
max-width: 100%;
|
||||
max-height: 25rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.capability-table table {
|
||||
width: 100%;
|
||||
min-width: 46rem;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.capability-table :where(th, td) {
|
||||
padding: 0.48rem 0.62rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.capability-table thead th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.capability-table tbody th {
|
||||
width: 24%;
|
||||
font-weight: 720;
|
||||
}
|
||||
.capability-table tbody tr:hover {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.state-chip,
|
||||
.risk-chip,
|
||||
.prompt-note {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 0.2rem 0.38rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.state-chip.available {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--device-success) 11%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--device-success);
|
||||
}
|
||||
.state-chip.unavailable,
|
||||
.state-chip.unknown {
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.risk-chip.low {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--device-success) 11%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--device-success);
|
||||
}
|
||||
.risk-chip.medium {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--device-warning) 11%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--device-warning);
|
||||
}
|
||||
.risk-chip.high,
|
||||
.prompt-note {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 10%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.probe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.probe-card {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
}
|
||||
.probe-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.probe-card > p {
|
||||
min-height: 2.4rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.probe-card > button {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
.probe-result {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.6rem;
|
||||
border-radius: 0.55rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.probe-result > strong {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.probe-result.complete > strong {
|
||||
color: var(--device-success);
|
||||
}
|
||||
.probe-result.denied > strong,
|
||||
.probe-result.error > strong {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.probe-result p {
|
||||
min-height: 0;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
.probe-result dl {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.probe-result dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
.probe-result dt {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.probe-result dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
.report-panel pre {
|
||||
max-height: 30rem;
|
||||
overflow: auto;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-text);
|
||||
font:
|
||||
0.73rem/1.55 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(38rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.help-dialog p,
|
||||
.help-dialog li {
|
||||
line-height: 1.55;
|
||||
}
|
||||
@media (max-width: 68rem) {
|
||||
.probe-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.principles,
|
||||
.inventory-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.inventory-toolbar button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 38rem) {
|
||||
.hero,
|
||||
.panel-heading {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
.probe-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.device-tools",
|
||||
"name": "Device Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect capabilities without fingerprinting.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["developer", "privacy", "system"],
|
||||
"tags": [
|
||||
"device",
|
||||
"browser",
|
||||
"capabilities",
|
||||
"permissions",
|
||||
"privacy",
|
||||
"webgl"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/device-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/device-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user