Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ paths-ignore:
- analysis/ghidra/service/server.py
- sandbox/ghosts/vendor/ghosts-src/Ghosts.Api/wwwroot/lib
- arcane/home/honeypot-cowrie/honeyfs-implant/main.go
- docs/design-lab
# #1763: docs/design-lab/ is the preserved 2026-08-17 design-lab record
# (restored from preserve-design-lab-1763 branch, kept byte-identical).
# playground/compare.html is a local-only harness (loads 127.0.0.1:<port>
# frames into an iframe for side-by-side theme comparison); it is not
# shipped, not served by any route, and reachable only by opening the file
# directly in a browser. CodeQL js/html DOM-text findings here are
# documentation-artifact noise, same class as the ghost vendor exclusion.
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ export function AppShell({

return (
<div className={shellClass}>
<a
href="#main"
className="skip-link sr-only"
onFocus={(event) => {
Object.assign(event.currentTarget.style, {
position: 'fixed',
top: '8px',
left: '8px',
width: 'auto',
height: 'auto',
overflow: 'visible',
clip: 'auto',
whiteSpace: 'normal',
zIndex: 9999,
background: 'var(--bg-raised, #fff)',
color: 'var(--text-000, #000)',
padding: '8px 16px',
borderRadius: '6px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
})
}}
onBlur={(event) => {
event.currentTarget.removeAttribute('style')
}}
>
Skip to main content
</a>
<CommandPalette />
<ProblemReportButton enabled={showProblemReportButton ?? false} />
<ConfirmHost />
Expand All @@ -120,7 +147,7 @@ export function AppShell({
{/* Click-to-dismiss backdrop behind the ≤520px drawer — visible only
while .hp-nav-open is on the shell (theme.css:2143-2158). */}
<div className="app-shell__nav-scrim" aria-hidden="true" onClick={() => setNavOpen(false)} />
<main className="app-main">
<main id="main" className="app-main">
<div className="app-content app-content--wide" data-hp-page-content>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ export function CommandPalette() {
rows={1}
placeholder="Investigate an IP, ASN, payload hash, session, HTTP path or free text…"
aria-label="Investigate an indicator"
role="combobox"
aria-expanded={rows.length > 0}
aria-controls="command-palette-listbox"
aria-activedescendant={activeRow >= 0 ? `command-palette-option-${activeRow}` : undefined}
aria-autocomplete="list"
value={query}
onChange={(event) => {
setQuery(event.target.value)
Expand All @@ -209,13 +214,15 @@ export function CommandPalette() {
</button>
</form>
{rows.length > 0 ? (
<div className="command-palette__results" role="listbox" aria-label="Search results">
<div id="command-palette-listbox" className="command-palette__results" role="listbox" aria-label="Search results">
{rows.map((row, index) => {
const active = index === activeRow
return (
<button
key={`${row.group}:${row.title}:${index}`}
id={`command-palette-option-${index}`}
type="button"
tabIndex={-1}
className={active ? 'command-palette__row active' : 'command-palette__row'}
role="option"
aria-selected={active}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,38 @@ export function LiveToasts() {
}
}, [])

const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
useEffect(() => {
return () => {
for (const timer of timers.current.values()) clearTimeout(timer)
}
}, [])

const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id)
if (timer) clearTimeout(timer)
timers.current.delete(id)
setToasts((current) => current.filter((toast) => toast.id !== id))
}, [])

const scheduleDismiss = useCallback((id: number) => {
const timer = setTimeout(() => dismiss(id), TOAST_MS)
timers.current.set(id, timer)
}, [dismiss])

// Hover/focus pauses the clock so a toast under the pointer or keyboard
// focus doesn't vanish mid-read; leaving/blurring restarts a fresh timer.
const pauseDismiss = useCallback((id: number) => {
const timer = timers.current.get(id)
if (timer) clearTimeout(timer)
timers.current.delete(id)
}, [])

const show = useCallback((condition: Condition, message: string, severity: Severity) => {
const id = nextId.current++
setToasts((current) => [...current, { id, key: condition.key, message, severity, to: condition.to }])
setTimeout(() => setToasts((current) => current.filter((toast) => toast.id !== id)), TOAST_MS)
}, [])
scheduleDismiss(id)
}, [scheduleDismiss])

const poll = useCallback(async () => {
let health: SourceHealth | null
Expand Down Expand Up @@ -251,11 +278,26 @@ export function LiveToasts() {

if (toasts.length === 0) return null
return (
<div className="hp-toast-stack">
<div className="hp-toast-stack" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<Link key={toast.id} className={`toast hp-toast toast--${toast.severity}`} to={toast.to}>
{toast.message}
</Link>
<div
key={toast.id}
className={`toast hp-toast toast--${toast.severity}`}
onMouseEnter={() => pauseDismiss(toast.id)}
onMouseLeave={() => scheduleDismiss(toast.id)}
onFocus={() => pauseDismiss(toast.id)}
onBlur={() => scheduleDismiss(toast.id)}
>
<Link to={toast.to}>{toast.message}</Link>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => dismiss(toast.id)}
style={{ marginLeft: "auto", background: "transparent", border: 0, cursor: "pointer", font: "inherit", lineHeight: 1 }}
>
×
</button>
</div>
))}
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,21 @@ function RecentRunsCard({ owner, refreshToken }: { owner: string; refreshToken:
<>
<div className="project-grid" id="workbench-results-list">
{runs.map((run) => (
<div key={run.id} className="project-card" onClick={() => setSelected(selected === run.id ? null : run.id)}>
<div
key={run.id}
className="project-card"
role="button"
tabIndex={0}
aria-label={`Toggle details for ${run.recipe_name || run.recipe_id || 'one-off'}`}
aria-expanded={selected === run.id}
onClick={() => setSelected(selected === run.id ? null : run.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelected(selected === run.id ? null : run.id)
}
}}
>
<div className="project-card__header">
<span className="project-card__title">{run.recipe_name || run.recipe_id || 'one-off'}</span>
<div className="project-card__badges">
Expand Down
4 changes: 2 additions & 2 deletions branding/templates/web/apiary-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
--apiary-copper-fill-pressed: #c9684b;
--apiary-copper-text: #dc8264;
--apiary-copper-soft: rgba(217, 119, 87, 0.14);
--apiary-link: #6da7ec;
--apiary-link: #de866b;
--apiary-success: #79c99e;
--apiary-warning: #deb36a;
--apiary-danger: #dc7774;
Expand Down Expand Up @@ -64,7 +64,7 @@
--apiary-copper-fill-pressed: #bd7866;
--apiary-copper-text: #9f513a;
--apiary-copper-soft: rgba(199, 101, 72, 0.12);
--apiary-link: #2a78d6;
--apiary-link: #9b4f3a;
--apiary-success: #3f8764;
--apiary-warning: #9b6b25;
--apiary-danger: #b34f4c;
Expand Down
71 changes: 71 additions & 0 deletions docs/design-lab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Design lab

The tooling that produced the dashboard's current design and its colour
palettes. Recovered from a local scratch directory where it was the only
copy — see #1763.

Nothing here runs in production. It is kept because the stylesheet's own
comments refer to decisions ("pick 6E, supersedes 6D") whose evidence lived
nowhere durable, and because the palette generator is the only executable
record of *why* the themes are the colours they are.

Real hostnames, the internal server address and a captured attacker IP were
replaced with documentation-range placeholders when these files were moved
into the public repository. Nothing else was edited.

## Contents

| file | what it is |
|---|---|
| `gen_palettes.py` | Generates the accent palettes and auto-tunes each until it clears WCAG AA. Holds the seed hues and the pair table. |
| `contrast_scan.js` | The scanning half of the same. |
| `palettes.css` | Generated output — the block that was pasted into `theme.css`. |
| `playground/` | `elements.html` is the option gallery Xore picked from: per-component variants, choices captured in `localStorage`, with a "copy picks" export. `compare.html` is a drag-split variant comparison. |
| `v5-picks-override.css` | A named variant as a token-override block layered on the vendored `theme.css` — the authoring pattern the variants were built with. |
| `design-notes.md` | The raw review log the picks came out of: per-page findings, the reference-app pattern catalog, and the directives collected during review. |

## The two rounds

The pick letters in `theme.css` comments refer to the **second** round. Both
are recorded in merged PRs:

| round | picks | shipped as |
|---|---|---|
| Design refresh | elements `1D/2D/3B/4C/5D/6D/7D/8B/9C/10C`, layouts `OV-B/EV-B/AS-C/RP-C`, shell `11C/12B/13B/14B` | #1573 + `Xore/theme#79` |
| **Design refresh 2 — "claude-pure"** *(current)* | all-**E** element set, with `scroll:D events:D sources:D reports:D` | #1588 + `Xore/theme#84`–`#89`, pin `68e3f09` |

The live design is the second: floating pill topbar with breadcrumb and
avatar, focus-column overview, minute-grouped event feed with the normalized
record pane opening only on row click, profile-card source grid,
document-style reports studio, circular scroll button, serif empty states
with line icons.

## Standing rules set during that session

- No lazy-loading. A "view more" button instead.
- Anything populated from Elasticsearch renders skeletons first, then
hydrates.
- One page width across all pages; the overview is full-width but keeps its
focus elements.
- SPA feel: only the centre content refreshes, never a full browser reload.

## Colour research

The palettes came from a 2026 UI colour-trend pass, not a brand benchmark.
Its findings were mostly about **grounds and neutrals** — elevated neutrals
(soft greys, warm sand, stone, muted clay, oatmeal, taupe), zinc/slate as the
dominant direction for technology products, lime with cool whites for
dashboards, "avoid grey-on-grey fatigue" in dark mode, and earth tones for a
sense of long-term value.

Only the accent half was implementable at the time, because a preset could
reach eight accent tokens. Finishing the other half is #1753.

## Rebuilding the harness

The original lab served variant builds against real Elasticsearch data on
ports 19201–19205, driven by an env-guarded Go test that booted the dashboard
with a stubbed OIDC session, a `STATIC_DIR` override and nil write-services
so the real index stayed read-only. That harness depended on the Go dashboard
and went away with it. A `frontend-next` equivalent needs the same read-only
guarantees; scoped separately.
59 changes: 59 additions & 0 deletions docs/design-lab/contrast_scan.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// WCAG contrast scan for the current page (design lab).
// Returns unique low-contrast (fg,bg) pairs with a sample selector + text.
(() => {
const lum = (r, g, b) => {
const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const parse = c => {
const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null;
};
const blend = (top, bottom) => {
const a = top[3];
return [top[0] * a + bottom[0] * (1 - a), top[1] * a + bottom[1] * (1 - a), top[2] * a + bottom[2] * (1 - a), 1];
};
const effBg = el => {
let bg = [32, 32, 31, 1]; // app ground fallback
const chain = [];
for (let n = el; n && n !== document.documentElement; n = n.parentElement) chain.push(n);
chain.reverse().forEach(n => {
const c = parse(getComputedStyle(n).backgroundColor);
if (c && c[3] > 0) bg = blend(c, bg);
});
return bg;
};
const ratio = (a, b) => {
const l1 = lum(a[0], a[1], a[2]), l2 = lum(b[0], b[1], b[2]);
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
};
const seen = new Map();
document.querySelectorAll("body *").forEach(el => {
if (!el.offsetParent && getComputedStyle(el).position !== "fixed") return;
const text = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent.trim()).join(" ").trim();
if (!text) return;
const cs = getComputedStyle(el);
const fg = parse(cs.color);
if (!fg || fg[3] < 0.1) return;
const bg = effBg(el);
const fgB = fg[3] < 1 ? blend(fg, bg) : fg;
const r = ratio(fgB, bg);
const size = parseFloat(cs.fontSize);
const bold = parseInt(cs.fontWeight, 10) >= 700;
const large = size >= 24 || (size >= 18.66 && bold);
const threshold = large ? 3 : 4.5;
if (r >= threshold) return;
const key = cs.color + "|" + JSON.stringify(bg.map(Math.round)) + "|" + (el.className || el.tagName);
if (seen.has(key)) return;
seen.set(key, {
ratio: Math.round(r * 100) / 100,
need: threshold,
color: cs.color,
bg: "rgb(" + bg.slice(0, 3).map(Math.round).join(",") + ")",
el: el.tagName.toLowerCase() + (el.className ? "." + String(el.className).trim().split(/\s+/).slice(0, 2).join(".") : ""),
text: text.slice(0, 40),
});
});
return [...seen.values()].sort((a, b) => a.ratio - b.ratio).slice(0, 25);
})()
Loading
Loading