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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user