T6.1/T6.2 - CR-001 and CR-003: the schedule driver and the urgency

Two fields and one board column mechanism, committed together because the second
is only there for the first: the board had NO sorting at all, CR-001 asks for a
sortable column, and CR-003 asks for another. Building the mechanism twice, or
building it once and pretending the second task got it free, are both worse than
saying so.

CR-001 - P6 activity ID and description

  Every work package traces back to the schedule activity that drives it, so a
  date on a package is anchored rather than floating. Placed beside the due
  date, which is where the meeting put it and the reason it is there.

  Free text. A validated lookup against an imported activity list is deferred
  (BL-000a) partly because the Micron schedule is being reworked - importing it
  now would import churn.

  Both fields live inside General Information, so CR-006's toggle governs them
  without any further wiring. The probe checks that by turning the section off
  and reading the rendered document, rather than by asserting they are in the
  right <div>.

CR-003 - Priority

  Three levels, agreed live in the meeting, and no fourth. Normal is the
  baseline default, and a package saved before today reads as Normal rather than
  blank - blank would sort and filter as an invisible fourth level.

  Sorted by ESCALATION, not alphabetically. High/Normal/Urgent would put the
  most urgent last, which is the one thing the column exists to prevent. The
  probe asserts the order AND that it is not the sorted order.

  Colour is never the only signal. The label is always rendered; the three
  differ by fill as well as by hue (outline / amber / red). Every value is a
  canonical token - X7's warning is that without one source of truth for colour,
  Normal/High/Urgent gets four implementations. 0 colour literals in the
  creator's stylesheet, asserted rather than assumed.

  Independent of status: the probe changes priority and checks the status radio
  did not move, then checks collectPackage reports the new priority with the old
  status.

Sorting, and what "including with empty values" had to decide

  EMPTIES LAST, in both directions. Ascending by P6 activity means "the ones
  with an activity, in order, then the ones without", because nobody sorts by a
  column in order to look at the rows that have nothing in it. Reversing the
  direction reverses the filled rows and leaves the blanks where they are. The
  probe checks both directions and that no row is lost either way.

  A non-numeric value in a numeric column is neither empty nor a number; it
  sorts after the numbers rather than as NaN, which compares false against
  everything and leaves the order undefined.

  Every sortable header is a real <button> inside its <th>, so it is in the tab
  order and Enter/Space work without being wired up. The direction is exposed
  through aria-sort on the th as well as drawn as an arrow, and the sorted
  column is bold - three channels (C1). Gates and the actions column are not
  sortable and therefore are not offered as buttons.

  html/wp-creation-index.html   two P6 fields, the priority select
  html/wp-creation-app.js       DASH_COLUMNS, dashSortRows, dashHeaderCells,
                                WP_PRIORITIES, wpPriorityOf, priorityPill
  html/wp-creation-styles.css   .dash-sort, .prio
  tests/generalinfo_check.py    new - 49 checks

Done when — CR-001
  [x] both fields exist, persist, and survive a reload (saved, reloaded, reopened)
  [x] Activity ID renders next to Due Date on the detail view
  [x] the column sorts correctly, including with empty values
  [x] both fields appear on the PDF export
  [x] the fields respect the CR-006 section toggles

Done when — CR-003
  [x] exactly three values; Normal is the default on a new work package
  [x] the dashboard filters and sorts by priority
  [x] priority colours come from canonical tokens; no raw hex added
  [x] colour is not the only signal - the label is always present
  [x] priority prints on the PDF export
  [x] changing priority does not alter status

No migration. Both fields live in the work package's JSON data blob, which is
where every other per-package field lives; nothing in server/models.py changed.

Verified one at a time
  generalinfo_check 49/49  new
  browser_check     71/71
  sections_check    88/88
  a11y              22/22
  pipeline          43/43
  url_state         23/23
  aggregates        16/16
  f_items           F1-F5 FIXED, F6 REPRODUCES (T7.2)

One note on running these: two of the runs above aborted with "browser would not
start after 3 attempts". That is the documented back-to-back port exhaustion,
not a code fault - both passed after a pause. The brief warns about it and it is
real.

Question for the PR, per CLAUDE.md: priority has no effect on anything yet - it
does not sort the board by default, does not affect release readiness, and does
not appear on the field view. It is a label the planner sets and a filter the
dashboard offers. If Urgent is meant to DO something, that is a separate item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 14:21:57 -05:00
parent 7893a56ea2
commit ce2d008897
4 changed files with 658 additions and 8 deletions

View File

@@ -44,6 +44,13 @@ const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed'];
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
// Acumatica cost codes (comment 10) — code|description
// CR-003. Exactly three, in escalating order — the order is the sort order, and
// it is why priority does not sort alphabetically (High, Normal, Urgent would put
// the most urgent last). Do not add a fourth: the meeting settled on three, and a
// fourth level is how a priority scale stops meaning anything.
const WP_PRIORITIES = ['Normal', 'High', 'Urgent'];
const WP_PRIORITY_DEFAULT = 'Normal';
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
// Acumatica allowed units of measure (comment 15) — common first
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
@@ -1123,6 +1130,10 @@ function collectPackage(){
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
p6Id:gv('wp_p6_id'), p6Desc:gv('wp_p6_desc'), // CR-001
priority:wpPriorityOf({priority: gv('wp_priority')}), // CR-003
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'),
assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
// Account ids behind those names — notification routing needs an account;
@@ -1229,7 +1240,9 @@ function renderPackage(pkg){
${fieldOn('acumaticaTask')?`<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>`:''}
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
<tr><th>Distribution</th><td>${cell(pkg.distribution)}</td></tr>
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
<tr><th>Priority</th><td>${esc(wpPriorityOf(pkg))}</td></tr>
<tr><th>Due Date</th><td>${cell(pkg.due)}${pkg.p6Id?` <span style="color:var(--text-dim)">· P6 activity ${esc(pkg.p6Id)}</span>`:''}</td></tr>
<tr><th>P6 Activity</th><td>${pkg.p6Id?esc(pkg.p6Id)+(pkg.p6Desc?' — '+esc(pkg.p6Desc):''):ns()}</td></tr>
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
@@ -1829,6 +1842,8 @@ function loadPackageIntoForm(p){
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
set('wp_p6_id',p.p6Id); set('wp_p6_desc',p.p6Desc); // CR-001
set('wp_priority', wpPriorityOf(p)); // CR-003
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
loadPeopleFromPkg(p);
set('wp_assignee',p.assigneeId);
@@ -1914,8 +1929,9 @@ function newPackage(){
// to return to.
if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true});
editingId=null;
['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
['wp_subject','wp_system','wp_location','wp_p6_id','wp_p6_desc','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
onClashChange();
pkgKind='iwp'; applyKind();
@@ -1957,7 +1973,7 @@ const WPData = {
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
};
let dashFilter={status:'',discipline:'',q:'',flag:''};
let dashFilter={status:'',discipline:'',q:'',flag:'',priority:''};
// A write has to reach the server before the server can count it. The outbox is
// the only path writes take, so flush it and then re-read - otherwise the refresh
// races the push and shows the pre-write totals, which is the same stale number
@@ -1979,7 +1995,7 @@ function dashRefreshAfterWrite(){
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
function dashToggleFlag(f, opts){
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; }
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:''}; }
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
dashPage=0;
// S3: which slice of the board you are looking at is state, so it belongs in
@@ -2009,6 +2025,84 @@ function dashApplyFlag(f){
}
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
// ── Board sorting (CR-001 / T6.1) ────────────────────────────────────────────
// The board had no sorting at all. CR-001 asks for a sortable P6 activity column
// and CR-003 asks for a sortable priority one, so the mechanism is built once and
// declared as data — a column added later is a row in this table, not another
// hand-written <th> and another comparator.
//
// EMPTIES SORT LAST, in both directions. That is the whole of "sorts correctly,
// including with empty values": ascending by P6 activity means "the ones with an
// activity, in order, then the ones without", because nobody sorts by a column in
// order to look at the rows that have nothing in it. Reversing the direction
// reverses the filled rows and leaves the blanks where they are.
const DASH_COLUMNS = [
{key:'number', label:'WP #', get:p => p.number || ''},
{key:'subject', label:'Subject', get:p => p.subject || ''},
{key:'type', label:'Type', get:p => p.type || ''},
{key:'disciplines', label:'Discipline', get:p => (p.disciplines || []).join(', ')},
{key:'p6Id', label:'P6 activity',get:p => p.p6Id || ''},
// Sorted by the ESCALATION order, not alphabetically: High, Normal, Urgent
// would put the most urgent last, which is the one thing the column is for.
{key:'priority', label:'Priority', numeric:true,
get:p => WP_PRIORITIES.indexOf(wpPriorityOf(p))},
{key:'status', label:'Status', get:p => p.status || ''},
{key:'gates', label:'Gates', sortable:false},
{key:'due', label:'Due', get:p => p.due || ''},
{key:'hours', label:'Hrs', numeric:true, get:p => p.hours},
{key:'actions', label:'', sortable:false},
];
let dashSort = {key:'', dir:1};
function dashSetSort(key){
const col = DASH_COLUMNS.find(c => c.key === key);
if(!col || col.sortable === false) return;
dashSort = (dashSort.key === key) ? {key: key, dir: -dashSort.dir} : {key: key, dir: 1};
dashPage = 0;
renderDashboard();
}
function dashSortRows(rows){
const col = DASH_COLUMNS.find(c => c.key === dashSort.key);
if(!col || col.sortable === false) return rows;
const blank = v => v === null || v === undefined || String(v).trim() === '';
// A copy: `rows` is derived from the live list and sorting it in place would
// reorder the board's own source.
return rows.slice().sort((a, b) => {
const va = col.get(a), vb = col.get(b);
const ea = blank(va), eb = blank(vb);
if(ea && eb) return 0;
if(ea) return 1; // empties last, whichever way the arrow points
if(eb) return -1;
if(col.numeric){
const na = parseFloat(va), nb = parseFloat(vb);
// A non-numeric value in a numeric column is not an empty and is not a
// number either; it sorts after the numbers rather than as NaN, which
// compares false against everything and leaves the order undefined.
if(isNaN(na) && isNaN(nb)) return 0;
if(isNaN(na)) return 1;
if(isNaN(nb)) return -1;
return (na - nb) * dashSort.dir;
}
return String(va).localeCompare(String(vb), undefined, {numeric:true, sensitivity:'base'})
* dashSort.dir;
});
}
function dashHeaderCells(){
return DASH_COLUMNS.map(c => {
if(c.sortable === false) return `<th>${esc(c.label)}</th>`;
const on = dashSort.key === c.key;
const aria = on ? (dashSort.dir > 0 ? 'ascending' : 'descending') : 'none';
const arrow = on ? (dashSort.dir > 0 ? ' ▲' : ' ▼') : '';
// A real button inside the th, so it is in the tab order and Enter/Space work
// for free (C1). The direction is said by aria-sort as well as by the arrow.
return `<th aria-sort="${aria}"><button type="button" class="dash-sort${on ? ' is-sorted' : ''}"`
+ ` onclick="dashSetSort('${c.key}')">${esc(c.label)}<span aria-hidden="true">${arrow}</span></button></th>`;
}).join('');
}
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
let dashPage=0, dashShowArchived=false, dashArchived=[];
const DASH_PAGE_SIZE=25;
@@ -2043,6 +2137,22 @@ function statusPill(s){
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
}
// A package saved before CR-003 has no priority at all, and so does one whose
// value has been hand-edited to something not on the list. Both mean Normal —
// the baseline — rather than blank, which would sort and filter as its own
// invisible fourth level.
function wpPriorityOf(p){
const v = (p && p.priority) || '';
return WP_PRIORITIES.indexOf(v) >= 0 ? v : WP_PRIORITY_DEFAULT;
}
function priorityPill(p){
const v = wpPriorityOf(p);
// The LABEL is always present. The colour is a second channel, never the only
// one (C1 / X7), and every one of them is a canonical token.
return `<span class="prio prio-${v.toLowerCase()}">${esc(v)}</span>`;
}
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
// Predecessor packages of `p` that aren't Closed. Deleted ones don't block.
@@ -2174,10 +2284,12 @@ function renderDashboard(){
const statusOpts=['<option value="">All statuses</option>'].concat(STATUS_ORDER.concat(['Issue']).map(s=>`<option ${dashFilter.status===s?'selected':''}>${esc(s)}</option>`)).join('');
const discList=Object.keys(byDisc);
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
const prioOpts=['<option value="">All priorities</option>'].concat(WP_PRIORITIES.map(v=>`<option ${dashFilter.priority===v?'selected':''}>${esc(v)}</option>`)).join('');
h+=`<div class="dash-filters">
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'&quot;')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
<select aria-label="Filter by priority" onchange="dashFilter.priority=this.value;dashPage=0;renderDashboard()">${prioOpts}</select>
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
</div>`;
@@ -2187,6 +2299,7 @@ function renderDashboard(){
const rows=boardSource.filter(p=>{
if(dashFilter.status && p.status!==dashFilter.status) return false;
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
if(dashFilter.priority && wpPriorityOf(p)!==dashFilter.priority) return false;
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false;
@@ -2194,11 +2307,12 @@ function renderDashboard(){
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
return true;
});
const totalRows=rows.length;
const sorted=dashSortRows(rows);
const totalRows=sorted.length;
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
if(dashPage>=pages) dashPage=pages-1;
if(dashPage<0) dashPage=0;
const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
const pageRows=sorted.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
// The board is a LIST, not a rollup: it renders the packages this browser holds,
// which is what keeps the field view usable offline. Its header is therefore the
// only count on this page not computed by the server — so it is reconciled against
@@ -2211,8 +2325,8 @@ function renderDashboard(){
? ` <span class="dash-chip chip-red" title="The server counts ${m.total} package(s) on this project; this browser is holding ${localCountable}. Usually a save that has not finished syncing.">this browser has ${localCountable} of ${m.total}</span>`
: '';
h+=`<div class="dash-panel"><div class="dash-panel-title">Work packages (${totalRows})${drift}</div>
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
if(!totalRows) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
<table class="dash-table"><thead><tr>${dashHeaderCells()}</tr></thead><tbody>`;
if(!totalRows) h+=`<tr><td colspan="${DASH_COLUMNS.length}" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
pageRows.forEach(p=>{
const ix=savedPackages.findIndex(x=>x.id===p.id);
const open=wpOpenConstraints(p).length;
@@ -2234,6 +2348,8 @@ function renderDashboard(){
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
<td style="font-size:11px" title="${esc(p.p6Desc||'')}">${cell(p.p6Id)}</td>
<td>${priorityPill(p)}</td>
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
<td class="center" style="white-space:nowrap">${actions}</td></tr>`;
});

View File

@@ -180,7 +180,26 @@
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
<div class="people-pick" id="pick_distribution"></div>
<input type="hidden" id="wp_distribution"></div>
<!-- CR-003. Three levels, agreed live in the meeting, and there is no fourth.
Independent of status: a package can become Urgent after it is issued
without its status moving. -->
<div class="field"><label>Priority <span class="req">*</span><span class="help-tip" data-tip="How urgent this package is, independently of its status and its due date. Normal is the baseline; High and Urgent are exceptions and are meant to stay rare.">i</span></label>
<select id="wp_priority">
<option value="Normal" selected>Normal</option>
<option value="High">High</option>
<option value="Urgent">Urgent</option>
</select></div>
<div class="field"><label>Due date</label><input type="date" id="wp_due"></div>
<!-- CR-001. Beside the due date on purpose: a date on a work package that
is not anchored to a schedule activity is a date floating on its own,
and the meeting placed the activity next to it for exactly that reason.
Free text — a validated lookup against an imported P6 activity list is
deferred (BL-000a) partly because the Micron schedule is being reworked,
and importing it now would import churn. -->
<div class="field"><label>P6 activity ID<span class="help-tip" data-tip="The Primavera P6 schedule activity this package delivers. Free text for now — a validated lookup against an imported activity list is deferred while the schedule is being reworked.">i</span></label>
<input type="text" id="wp_p6_id" placeholder="e.g. A1234"></div>
<div class="field"><label>P6 activity description</label>
<input type="text" id="wp_p6_desc" placeholder="what that activity covers"></div>
<div class="field"><label>Specification section</label>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>

View File

@@ -904,6 +904,41 @@
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
.dash-table td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; }
/* CR-001 / T6.1: a sortable header is a real <button> inside the <th>, so it is
in the tab order and Enter and Space work without being wired up. It inherits
the header's own type rather than declaring its own, so the row still reads as
one strip. A th with no sort (Gates, the actions column) has no button and is
therefore not offered as one. */
.dash-table th .dash-sort {
background:none; border:none; padding:0; margin:0; border-radius:0;
font:inherit; color:inherit; text-transform:inherit; letter-spacing:inherit;
cursor:pointer; display:inline-flex; align-items:center; gap:4px;
}
.dash-table th .dash-sort:hover { color:var(--accent); }
/* The sorted column is said three ways: bold, an arrow, and aria-sort on the
th — so it survives both "cannot see colour" and "cannot see the arrow". */
.dash-table th .dash-sort.is-sorted { color:var(--text); font-weight:700; }
/* CR-003 / X7: priority, coloured from canonical tokens only. X7's warning is
that without one source of truth for colour, Normal/High/Urgent gets four
implementations — so these alias the same status tokens the badges and
banners already use, and declare nothing.
The LABEL is always rendered. Colour is a second channel and never the only
one: Normal is a plain outline, High is amber-filled and Urgent is
red-filled, so the three differ by weight and fill as well as by hue. */
.prio {
display:inline-block; padding:1px 8px; border:1px solid var(--border-strong);
border-radius:20px; font-size:10px; font-weight:600; letter-spacing:.04em;
color:var(--text-muted); background:var(--surface); white-space:nowrap;
}
.prio-high {
color:var(--accent-amber); border-color:var(--wp-status-warning-border-a);
background:var(--accent-amber-dim);
}
.prio-urgent {
color:var(--wp-btn-danger-fill-fg); border-color:var(--red); background:var(--red);
}
.dash-filters { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
.dash-filters input[type=search] { flex:1; min-width:200px; }