refactor(m4): match attack.mitre.org sizing — equal-width cols, name-only cells

Visual parity pass against attack.mitre.org/# per user feedback ("trop dense,
illisible, je veux la même représentation"):

- Layout switched from flex+fixed-width 224px columns to a CSS grid of
  `repeat(N, minmax(0, 1fr))` so the 15 tactic columns share the container
  width equally. No more horizontal scroll on a standard desktop.
- Cells now show NAME ONLY (matches mitre.org). The external_id (TA00xx /
  T1xxx / T1xxx.xxx) is preserved in the chip selection bar at the top and
  in the `title` hover tooltip on every cell — surfaces on demand, doesn't
  consume cell real estate.
- Font: switched to `font-sans` (IBM Plex Sans) at `text-xs` (12px) across
  cells, matching the mitre.org typography. Headers use the same family at
  the same size with a 10px sub-line for the technique count.
- Chevron icons: ▸ (collapsed) / ▾ (expanded) — small, sub-technique count
  rendered inline beside the chevron.
- Helper line below the matrix tells the user where the IDs went.

Spec §F2 + testing-m4.md walkthrough rewritten to lock the new sizing rules
in (font-xs, no external_id in cells, hover/chip for the ID, no horizontal
scroll). spec-reviewer will see the matching contract.

DoD: make e2e → 34 passed. Selectors (data-testid + aria-pressed) unchanged
so the existing M4 e2e test still walks the new layout end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-12 18:41:11 +02:00
parent 7dbe2dbc28
commit 8742fb2b6e
3 changed files with 150 additions and 157 deletions

View File

@@ -30,12 +30,11 @@ function useMatrix() {
}
/**
* Flat ATT&CK matrix in the attack.mitre.org style — columns = tactics, each
* cell lists its techniques with an inline chevron to expand sub-techniques.
*
* Click a technique row → toggle selection. Click a sub-technique row → toggle.
* Click the tactic header → toggle the whole tactic as a single tag.
* Selected items are filled with their accent (cyan/orange/purple).
* Flat ATT&CK matrix in the attack.mitre.org/# style — 15 columns share the
* available width (no horizontal scroll on standard desktop). Cells show the
* technique NAME only; the external_id surfaces in the chips at the top and in
* the hover tooltip. A `▸/▾` chevron beside a technique expands its
* sub-techniques inline within the column.
*/
export function MitreTagPicker({ value, onChange, className }: MitreTagPickerProps) {
const matrix = useMatrix();
@@ -44,7 +43,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
const filterNorm = filter.trim().toLowerCase();
// Set of `${kind}:${external_id}` for O(1) lookup.
// O(1) selection lookup.
const selectedKeys = useMemo(
() => new Set(value.map((t) => `${t.kind}:${t.external_id}`)),
[value],
@@ -114,7 +113,7 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
className="max-w-md"
/>
{matrix.data && (
<span className="font-mono text-2xs text-text-dim mb-2 whitespace-nowrap">
<span className="font-sans text-xs text-text-dim mb-2 whitespace-nowrap">
{matrix.data.tactics.length} tactics ·{' '}
{matrix.data.tactics.reduce((sum, t) => sum + t.techniques.length, 0)} mappings
</span>
@@ -128,164 +127,156 @@ export function MitreTagPicker({ value, onChange, className }: MitreTagPickerPro
{matrix.data && (
<div
className="overflow-x-auto pb-2"
data-testid="mitre-matrix-scroll"
role="region"
aria-label="MITRE ATT&CK matrix"
/* Equal-width columns share the container; no horizontal scroll. */
className="grid gap-px bg-border rounded"
style={{
gridTemplateColumns: `repeat(${matrix.data.tactics.length}, minmax(0, 1fr))`,
}}
>
<div className="flex gap-2 min-w-max items-start">
{matrix.data.tactics.map((tactic) => {
const visible = tactic.techniques.filter(matches);
const tacticSel = isSelected('tactic', tactic.external_id);
return (
<div
key={tactic.id}
className="w-56 shrink-0"
data-testid={`mitre-column-${tactic.external_id}`}
{matrix.data.tactics.map((tactic) => {
const visible = tactic.techniques.filter(matches);
const tacticSel = isSelected('tactic', tactic.external_id);
return (
<div
key={tactic.id}
className="bg-bg-base flex flex-col min-w-0"
data-testid={`mitre-column-${tactic.external_id}`}
>
{/* Tactic header — name only (attack.mitre.org style) */}
<button
type="button"
onClick={() =>
toggle({
kind: 'tactic',
id: tactic.id,
external_id: tactic.external_id,
name: tactic.name,
})
}
className={cn(
'w-full text-left px-2 py-1.5 font-sans border-b transition',
tacticSel
? 'accent-fill-cyan border-cyan text-text-bright'
: 'bg-bg-card border-border hover:bg-cyan/10 text-text-bright',
)}
title={`${tactic.external_id}${tactic.name}`}
data-testid={`mitre-tactic-${tactic.external_id}`}
aria-pressed={tacticSel}
>
{/* Sticky-ish tactic header (click to select the whole tactic). */}
<button
type="button"
onClick={() =>
toggle({
kind: 'tactic',
id: tactic.id,
external_id: tactic.external_id,
name: tactic.name,
})
}
className={cn(
'w-full text-left rounded-t-md border-2 px-2 py-2 font-mono text-2xs transition',
tacticSel
? 'accent-fill-cyan border-cyan text-text-bright'
: 'border-cyan/40 hover:border-cyan hover:bg-cyan/5',
)}
data-testid={`mitre-tactic-${tactic.external_id}`}
aria-pressed={tacticSel}
>
<div className="flex items-center justify-between gap-1">
<span className="text-cyan font-semibold">{tactic.external_id}</span>
<span className="text-3xs text-text-dim">
{tactic.techniques.length}
</span>
</div>
<div className="text-2xs mt-0.5 truncate" title={tactic.name}>
{tactic.name}
</div>
</button>
<div className="text-xs font-semibold leading-tight break-words">
{tactic.name}
</div>
<div className="text-[10px] text-text-dim mt-0.5">
{tactic.techniques.length} technique{tactic.techniques.length === 1 ? '' : 's'}
</div>
</button>
{/* Techniques cells */}
<div className="border-x border-b border-border rounded-b-md bg-bg-base">
{visible.length === 0 && filterNorm && (
<div className="px-2 py-1 font-mono text-3xs text-text-dim">
(filtered out)
</div>
)}
{visible.map((tech) => {
const techSel = isSelected('technique', tech.external_id);
const isExpanded = expanded.has(tech.external_id);
const hasSubs = tech.subtechniques.length > 0;
return (
<div key={tech.id} className="border-t border-border first:border-t-0">
<div
className={cn(
'flex items-stretch text-2xs',
techSel ? 'accent-fill-orange' : 'hover:bg-bg-card',
)}
{/* Techniques cells */}
<div className="flex flex-col">
{visible.length === 0 && filterNorm && (
<div className="px-2 py-1 font-sans text-[10px] text-text-dim italic">
(filtered out)
</div>
)}
{visible.map((tech) => {
const techSel = isSelected('technique', tech.external_id);
const isExpanded = expanded.has(tech.external_id);
const hasSubs = tech.subtechniques.length > 0;
return (
<div key={tech.id} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-stretch text-xs',
techSel
? 'accent-fill-orange text-text-bright'
: 'hover:bg-orange/10',
)}
>
<button
type="button"
onClick={() =>
toggle({
kind: 'technique',
id: tech.id,
external_id: tech.external_id,
name: tech.name,
})
}
className="flex-1 min-w-0 text-left px-2 py-1.5 font-sans break-words"
title={`${tech.external_id}${tech.name}`}
data-testid={`mitre-technique-${tech.external_id}`}
aria-pressed={techSel}
>
<span className="leading-tight">{tech.name}</span>
</button>
{hasSubs && (
<button
type="button"
onClick={() =>
toggle({
kind: 'technique',
id: tech.id,
external_id: tech.external_id,
name: tech.name,
})
}
className="flex-1 text-left px-2 py-1 font-mono"
data-testid={`mitre-technique-${tech.external_id}`}
aria-pressed={techSel}
onClick={() => toggleExpand(tech.external_id)}
className={cn(
'shrink-0 px-1 border-l text-[10px] font-mono leading-none',
techSel
? 'border-text-bright/30 text-text-bright hover:bg-text-bright/10'
: 'border-border text-purple hover:bg-purple/10',
)}
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${tech.external_id} sub-techniques`}
aria-expanded={isExpanded}
data-testid={`mitre-expand-${tech.external_id}`}
title={`${tech.subtechniques.length} sub-technique${tech.subtechniques.length === 1 ? '' : 's'}`}
>
<span
className={cn(
'font-semibold mr-1',
techSel ? 'text-text-bright' : 'text-orange',
)}
>
{tech.external_id}
</span>
<span title={tech.name}>{tech.name}</span>
<span className="text-purple">{isExpanded ? '▾' : '▸'}</span>
<span className="ml-0.5 align-middle">{tech.subtechniques.length}</span>
</button>
{hasSubs && (
<button
type="button"
onClick={() => toggleExpand(tech.external_id)}
className="px-2 border-l border-border text-purple hover:bg-purple/10"
aria-label={`Toggle ${tech.external_id} sub-techniques`}
aria-expanded={isExpanded}
data-testid={`mitre-expand-${tech.external_id}`}
>
<span className="font-mono">
{isExpanded ? '' : '+'}
<span className="text-3xs ml-0.5">{tech.subtechniques.length}</span>
</span>
</button>
)}
</div>
{isExpanded && hasSubs && (
<div className="bg-bg-card border-t border-border">
{tech.subtechniques.map((sb) => {
const subSel = isSelected('subtechnique', sb.external_id);
return (
<button
key={sb.id}
type="button"
onClick={() =>
toggle({
kind: 'subtechnique',
id: sb.id,
external_id: sb.external_id,
name: sb.name,
})
}
className={cn(
'block w-full text-left pl-6 pr-2 py-1 font-mono text-2xs border-l-2',
subSel
? 'accent-fill-purple border-purple text-text-bright'
: 'border-purple/30 hover:bg-purple/5',
)}
data-testid={`mitre-subtechnique-${sb.external_id}`}
aria-pressed={subSel}
>
<span
className={cn(
'font-semibold mr-1',
subSel ? 'text-text-bright' : 'text-purple',
)}
>
{sb.external_id}
</span>
<span title={sb.name}>{sb.name}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
{isExpanded && hasSubs && (
<div className="bg-bg-card">
{tech.subtechniques.map((sb) => {
const subSel = isSelected('subtechnique', sb.external_id);
return (
<button
key={sb.id}
type="button"
onClick={() =>
toggle({
kind: 'subtechnique',
id: sb.id,
external_id: sb.external_id,
name: sb.name,
})
}
className={cn(
'block w-full text-left pl-5 pr-2 py-1 font-sans text-xs border-l-2 break-words',
subSel
? 'accent-fill-purple border-purple text-text-bright'
: 'border-purple/30 hover:bg-purple/10',
)}
title={`${sb.external_id}${sb.name}`}
data-testid={`mitre-subtechnique-${sb.external_id}`}
aria-pressed={subSel}
>
<span className="leading-tight">{sb.name}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
<p className="mt-3 font-mono text-3xs text-text-dim">
Click any cell to select · click <span className="text-purple">+N</span> to reveal sub-techniques · click a chip above to remove.
<p className="mt-3 font-sans text-[11px] text-text-dim">
Hover a cell for its <code className="font-mono text-purple">external_id</code>. Click a cell to toggle selection. Use <span className="text-purple font-mono"></span> to reveal sub-techniques inline. Click a chip above to remove.
</p>
</div>
);