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
13 changes: 13 additions & 0 deletions portal/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,19 @@
.sechead .dl-count{font-weight:400;color:var(--muted);text-transform:none;letter-spacing:0}
.dl-cite{font-size:11.5px;color:var(--muted);margin-top:6px}
.dl-instr{font-size:12px;color:var(--muted);margin-top:4px}
/* Drawer-polish lane (owner, 2026-08-19). The grid's three link sites - the tile identifiers (.dl-id),
the Rees et al. 2019 citation (.dl-cite) and the instruments platform PID (.dl-instr) - had NO colour
rule of their own and fell back to the user agent's link colours: a near-invisible dark blue on the
navy tiles, going purple once followed. Exactly the defect the .meta td a rule above fixed for the
summary tables, and it takes exactly that treatment - the site accent, underline on hover only, the
same value .dsub a.orglink and .surveymeta a already use. Nothing new is invented here.
:visited is stated EXPLICITLY rather than left to the cascade: an identifier is not "consumed" by
being clicked, so a followed DOI must read identically to an unfollowed one, and saying so keeps the
rule true if a later edit ever narrows the base selector. The muted tiles' "not yet recorded" text is
NOT a link and is deliberately absent from these selectors - it stays --muted. */
.dl-id a,.dl-cite a,.dl-instr a{color:var(--copper);text-decoration:none}
.dl-id a:visited,.dl-cite a:visited,.dl-instr a:visited{color:var(--copper)}
.dl-id a:hover,.dl-cite a:hover,.dl-instr a:hover{text-decoration:underline}
/* R5: the Files tab NCI-level list — single column of full-width rows (not the 2-col tile grid). */
.filelist{display:flex;flex-direction:column;gap:6px}
.filelist .prod{width:100%}
Expand Down
49 changes: 37 additions & 12 deletions portal/src/drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1500,7 +1500,7 @@ function surveyBundleTiles(slug){
`<span class="pdot" style="background:var(--ok)"></span><div>${esc(L[0])}<small>${esc(L[1])}${r.size?" · "+esc(fmtBytes(r.size)):""}</small></div></div>`;
}).join("");
}
// ---- survey-drawer lane (ruling 4, amended 2026-08-18): the survey PERSISTENT IDENTIFIERS tile grid ------
// ---- survey-drawer lane (ruling 4, amended 2026-08-18): the survey DATA AT EVERY LEVEL tile grid --------
// The block used to be a collapsed <details> of whatever single-value identifier rows happened to be
// recorded, so its LENGTH varied per survey and a reader could not see what a survey had NOT deposited.
// It is now a DATA-LEVEL grid: six fixed slots, always all six, rendered in the Downloads tile treatment.
Expand All @@ -1525,6 +1525,18 @@ const DATA_LEVEL_SLOTS=[
["level2","Level 2","derived frequency-domain processed data: transfer functions"],
["level3","Level 3","derived modelling inputs and outputs"],
];
// SLOT ALIASES (drawer-polish lane, owner 2026-08-19). `entire` - ONE record covering all levels, the
// shape the survey template gives a state-survey landing page - IS the umbrella record the Collection
// slot names, so it FILLS that slot instead of falling through to the extra-tile bucket. Gawler Phase 2
// is the case that forced this: its only umbrella identifier is the GSSA/SARIG record (identifies:
// entire), so the drawer read "1 of 6 recorded" with an empty Collection tile and an orphan hanging
// under the grid, when the survey plainly HAS deposited its umbrella record.
// COLLISION RULE: when a survey carries BOTH `collection` and `entire`, the EXACT key takes the slot and
// the alias renders as an EXTRA tile below the six. Two properties this preserves, in order: nothing is
// ever silently dropped (the extra-tile rule is the section's one answer to "recorded, but not one of the
// six"), and "N of 6" counts SLOTS, so a colliding pair tallies one, never two. Declaration order in the
// survey.yaml is irrelevant - the exact match wins wherever it sits in the list.
const SLOT_ALIASES={collection:["entire"]};
// One tile. UNRECORDED is the owner's explicit ruling: muted BUT VISIBLE (.prod.dis + a hollow dot +
// "not yet recorded"), never omitted, so the deposit chain has the same shape on every survey and a gap is
// legible as a gap. RECORDED renders the identifier with the SAME resolution honesty every other identifier
Expand All @@ -1544,29 +1556,42 @@ function dataLevelTile(name,desc,row){
const attrs=act?Object.entries(act).map(([k,v])=>`data-${k}="${escAttr(v)}"`).join(" "):"";
return `<div class="prod dl-tile" ${attrs}><span class="pdot" style="background:var(--ok)"></span>`+
`<div>${head}<small class="dl-id">${relatedIdLink(row.identifier,row.identifier_type)}${tag}</small></div></div>`;}
// The whole section: the six fixed slots, then any identifier that maps to NO slot as an EXTRA tile below
// them. Nothing is ever silently dropped - the `identifies` vocabulary may grow, and a row this build does
// The whole section: the six fixed slots, then any identifier NO slot claimed (directly or through
// SLOT_ALIASES) as an EXTRA tile below them. Nothing is ever silently dropped - the `identifies`
// vocabulary may grow, and a row this build does
// not model must still be visible rather than vanishing between releases. "N of 6" counts the six FIXED
// slots only (an extra tile is not one of the six), per the slot-mapping ruling.
function surveyDataLevelsHtml(m){
m=m||{};
const rels=(m.related_identifiers||[]).filter(r=>r&&typeof r==="object"&&r.identifier);
const rowFor=k=>rels.find(r=>r.identifies===k);
const have=DATA_LEVEL_SLOTS.filter(([k])=>!!rowFor(k)).length;
const tiles=DATA_LEVEL_SLOTS.map(([k,name,desc])=>dataLevelTile(name,desc,rowFor(k))).join("");
// Unmapped rows: an out-of-slot `identifies` (e.g. `entire`, one record covering all levels) or a legacy
// row that predates the level model and carries only a DataCite relation. Labelled by the same tables the
// retired Related-identifiers block used, so the label vocabulary is unchanged for these rows.
const slotKeys=DATA_LEVEL_SLOTS.map(([k])=>k);
const extras=rels.filter(r=>slotKeys.indexOf(r.identifies)<0).map(r=>{
// Resolve the six slots ONCE, recording which rows they consumed. With aliases in play, "this row's
// identifies is not a slot key" is no longer a safe proxy for "no slot took it", and getting that wrong
// would either drop a row or render it twice - so the consumed set is tracked explicitly and the extras
// bucket is derived from it. `taken` also makes single-consumption structural: no row can fill two slots.
const taken=[],slotRows=[];
DATA_LEVEL_SLOTS.forEach(([k])=>{
const pick=key=>rels.find(r=>r.identifies===key&&taken.indexOf(r)<0);
const row=pick(k)||(SLOT_ALIASES[k]||[]).map(pick).find(Boolean)||null; // exact key first, then aliases
if(row)taken.push(row);
slotRows.push(row);});
const have=slotRows.filter(Boolean).length;
const tiles=DATA_LEVEL_SLOTS.map(([,name,desc],i)=>dataLevelTile(name,desc,slotRows[i])).join("");
// Unclaimed rows: an out-of-slot `identifies`, the alias that LOST a collision (a survey declaring both
// `collection` and `entire`), or a legacy row that predates the level model and carries only a DataCite
// relation. Labelled by the same tables the retired Related-identifiers block used, so the label
// vocabulary is unchanged for these rows.
const extras=rels.filter(r=>taken.indexOf(r)<0).map(r=>{
const label=(r.identifies&&IDENTIFIES_LABELS[r.identifies])||RELATION_LABELS[r.relation]||(r.relation?String(r.relation):"Related identifier");
return dataLevelTile(label,"recorded identifier outside the six data levels",r);}).join("");
// The project RAiD is a PROJECT identifier, not a data level, so it has no slot - but it was visible in
// the block this grid replaces, and dropping it silently would lose a recorded identifier. It rides the
// same extra-tile mechanism, which is the section's one rule for "recorded, but not one of the six".
const raidRow=(m.raid&&!String(m.raid).startsWith("TODO"))?{identifier:String(m.raid),identifier_type:"URL"}:null;
const raid=raidRow?dataLevelTile("Project RAiD","the research activity this survey was acquired under",raidRow):"";
return `<div class="sechead">Persistent identifiers: <span class="dl-count">${have} of 6 recorded</span></div>`+
// Owner-approved wording from the design mockup (2026-08-19): the head names what the grid is FOR - the
// deposit chain, level by level - rather than the identifier machinery it happens to be made of. The
// STATION drawer's own "Persistent identifiers & instruments" block (identifiersHtml) keeps its name.
return `<div class="sechead">Data at every level: <span class="dl-count">${have} of 6 recorded</span></div>`+
`<div class="prodgrid">${tiles}${extras}${raid}</div>`+
// The citability IS the point of using a published scheme, so the grid says which one, in print.
`<div class="dl-cite">Levels per <a href="${escUrl(REES_LEVELS_DOI)}" target="_blank" rel="noopener noreferrer">Rees et al. 2019</a></div>`+
Expand Down
128 changes: 128 additions & 0 deletions portal/tests/test_drawer_link_styling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Drawer-polish lane (owner screenshot evidence, 2026-08-19): the survey data-level grid's links.

The grid shipped with THREE anchor sites that no CSS rule ever coloured - the tile identifier links
(.dl-id), the "Levels per Rees et al. 2019" citation link (.dl-cite) and the instruments platform-PID link
(.dl-instr). With no author colour declared they fell back to the user agent's link colours, which on the
navy (--panel-2 #1E2B4F) tiles are a near-invisible dark blue, going browser-purple once followed. It is
the same defect the `.meta td a` rule already fixed for the summary tables, and it takes the same fix.

WHAT EACH LAYER PROVES (the three are deliberately different failure modes, not three spellings of one):

* here, test_data_level_link_rules_reuse_the_established_treatment - the SHEET declares the treatment for
all three containers, at the value the portal's established link rules already use (read out of the
sheet, never hard-coded here: the lane's instruction was reuse, not a new colour), with :visited stated
explicitly. FAILS IF a rule is missing, if someone invents a second accent, or if :visited is left to
the browser. Needs no Node - it reads index.html.
* here, test_unrecorded_tile_state_text_stays_muted_not_link_coloured - the negative: an absent level's
"not yet recorded" is a statement, not a link, and must not be painted the accent.
* tools/interaction_test.js (section DP) - the CASCADE: every anchor the grid actually renders is
SELECTED by an accent rule, asserted with element.matches() against the real index.html stylesheet in
jsdom. That is the layer that catches a container being renamed or a new link site being added out of
the rules' reach; a string pin here could not.

Neither layer proves the RENDERED colour (jsdom resolves no custom properties and computes no cascade
beyond selector matching) - that remains a browser-eye check, and the owner's screenshot is the report.
"""
import re
import shutil
from pathlib import Path

import pytest

from test_related_identifiers_render import _render

ROOT = Path(__file__).resolve().parent.parent # portal/
INDEX = ROOT / "index.html"

# The three containers the grid puts links in, with the drawer.js site that emits each.
LINK_CONTAINERS = {
".dl-id": "the tile identifier links (DOIs / SARIG PIDs)",
".dl-cite": "the 'Levels per Rees et al. 2019' citation link",
".dl-instr": "the instruments platform-PID link",
}


def _stylesheet():
css = INDEX.read_text(encoding="utf-8")
css = css.split("<style>", 1)[1].split("</style>", 1)[0]
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)


def _rules(css):
"""Flat (selector_list, declarations) pairs, descending into @media/@supports blocks.

A naive `([^{}]+)\\{([^{}]*)\\}` scan would mis-parse this sheet - it carries @media and @keyframes
blocks - so walk the braces instead and recurse one level into any at-rule that holds rules.
"""
out, i, n = [], 0, len(css)
while i < n:
brace = css.find("{", i)
if brace < 0:
break
prelude = css[i:brace].strip()
depth, j = 1, brace + 1
while j < n and depth:
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
j += 1
body = css[brace + 1:j - 1]
if prelude.startswith("@"):
if not prelude.startswith("@keyframes"): # keyframe stops are not selectors
out.extend(_rules(body))
else:
out.append((prelude, body))
i = j
return out


def _colour_for(rules, selector):
"""The `color:` value declared by the rule whose selector list contains `selector`, else None."""
for prelude, body in rules:
if selector in [s.strip() for s in prelude.split(",")]:
m = re.search(r"(?:^|;)\s*color\s*:\s*([^;]+)", body)
if m:
return m.group(1).strip()
return None


def test_data_level_link_rules_reuse_the_established_treatment():
"""Each of the grid's three link containers gets a descendant-anchor colour AND an explicit :visited
colour, both equal to the value the portal's established link rules already carry. FAILS (RED before
this lane) IF any container has no colour rule - which is exactly how the DOI, citation and
platform-PID links shipped in the UA default - or IF a new accent is invented instead of reused."""
rules = _rules(_stylesheet())
# The established treatment, READ OUT OF THE SHEET: the organisation ROR link in the drawer subline and
# the publication DOIs inside .surveymeta. If those two ever disagree, this pin says so before comparing.
org = _colour_for(rules, ".dsub a.orglink")
pubs = _colour_for(rules, ".surveymeta a")
assert org and pubs, "the established link rules (.dsub a.orglink / .surveymeta a) are gone from index.html"
assert org == pubs, f"the two established link treatments disagree: {org!r} vs {pubs!r}"
for cls, what in LINK_CONTAINERS.items():
got = _colour_for(rules, f"{cls} a")
assert got, f"{what}: index.html declares no colour for '{cls} a' - the UA default ships"
assert got == org, f"{what}: '{cls} a' uses {got!r}, not the established treatment {org!r}"
vis = _colour_for(rules, f"{cls} a:visited")
assert vis, f"{what}: no ':visited' colour for '{cls} a' - a followed link may go browser-purple"
assert vis == org, f"{what}: '{cls} a:visited' uses {vis!r}, not the established treatment {org!r}"


@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js not available")
def test_unrecorded_tile_state_text_stays_muted_not_link_coloured(tmp_path):
"""The muted tiles' 'not yet recorded' state is a STATEMENT OF ABSENCE, not a link. It must stay in
the .dl-state span with no anchor, and no rule may paint .dl-state the accent (which would read as a
followable identifier). FAILS IF the state text is ever wrapped in an anchor or accent-coloured."""
_station, story, _card = _render(tmp_path, {}) # nothing recorded: all six tiles muted
states = re.findall(r'<small class="dl-state">(.*?)</small>', story)
assert len(states) == 6, f"expected six muted state lines, got {len(states)}:\n{story}"
for s in states:
assert s.strip() == "not yet recorded", f"unexpected state copy: {s!r}"
assert "<a " not in s, f"the 'not yet recorded' state became a link: {s!r}"
rules = _rules(_stylesheet())
org = _colour_for(rules, ".dsub a.orglink")
for prelude, body in rules:
if any(s.strip().endswith(".dl-state") for s in prelude.split(",")):
m = re.search(r"(?:^|;)\s*color\s*:\s*([^;]+)", body)
assert not m or m.group(1).strip() != org, \
f"the muted state text is painted the link accent by rule '{prelude.strip()}'"
Loading
Loading