diff --git a/dag/extdeps/google/issue_tracker.dag b/dag/extdeps/google/issue_tracker.dag index 4da13732b8e..2fd87103b70 100644 --- a/dag/extdeps/google/issue_tracker.dag +++ b/dag/extdeps/google/issue_tracker.dag @@ -64,7 +64,12 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { } }, first_citation: extdeps_external_authority_anchor, - further_citations: [issue_tracker_session_capture] + further_citations: [ + issue_tracker_session_capture, + issue_tracker_visual_capture_origin, + issue_tracker_visual_capture_local, + issue_tracker_visual_capture_shared, + ] } // Versioned by the capture date of the cited session: this interface model states what was @@ -79,7 +84,10 @@ data extdeps_model_scope: ExternalModelScope = ExternalModelScope { // email-shaped route validation) lands — owner rulings amending the capture-verified vocabulary. // Bumped 2026-09-27: descriptive types, modeled sort keys, and the uniform served // roster without TYPE or a project-only count slot (owner-directed amendments). -data issue_tracker_interface_version: NonEmptyStr = "2026-09-27" +// Bumped 2026-10-04: the measured design language (type, palette in both schemes, metrics, card, +// status chip and button anatomy) from the signed-out capture of that date, and the +// owner-directed dark token row replaced by the captured dark palette. +data issue_tracker_interface_version: NonEmptyStr = "2026-10-04" // The stock sidebar roster, verbatim from the owner's logged-in session (2026-09-22). The first // seven entries each have exactly one derivation fn below; the last four are navigation and @@ -442,28 +450,323 @@ data issue_tracker_visual_tokens: IssueTrackerVisualTokens = IssueTrackerVisualT font_heading: "Google Sans, Roboto, Arial, sans-serif", } -// THE DARK-SCHEME ROW, owner-directed 2026-09-23 (production defect: dark mode painted the -// light-only tokens on dark ground). The 2026-09-22 capture is a LIGHT capture; these are the -// product's dark-scheme equivalents for the SAME token set, per the owner's ruling — text -// #e8eaed / #9aa0a6 on surfaces #202124 / #292a2d, borders #3c4043 / #5f6368, action blue -// #8ab4f8 (hover #aecbfa), error #f28b82, highlight the dark selection blue. Same type, same -// slots, same hue roles — one token structure, two scheme values; the style layer emits one per -// scheme under the theme machinery's selector grammar. Re-capture against a saved dark session -// supersedes these rows under the interface version rule. -data issue_tracker_visual_tokens_dark: IssueTrackerVisualTokens = IssueTrackerVisualTokens { - text_primary: Srgb8 { r: 232, g: 234, b: 237 }, - text_secondary: Srgb8 { r: 154, g: 160, b: 166 }, - text_tertiary: Srgb8 { r: 154, g: 160, b: 166 }, - border_default: Srgb8 { r: 60, g: 64, b: 67 }, - border_strong: Srgb8 { r: 95, g: 99, b: 104 }, - background_subtle: Srgb8 { r: 32, g: 33, b: 36 }, - background_hover: Srgb8 { r: 41, g: 42, b: 45 }, - highlight: Srgb8 { r: 26, g: 58, b: 92 }, +// THE MEASURED DESIGN LANGUAGE, CAPTURED 2026-10-04. Everything from here to the action +// vocabulary was read from the running product with getComputedStyle and bounding boxes: the +// public Chromium instance (issues.chromium.org), signed out, headless Chromium at 1440x1100 CSS +// px, device scale 1. The screenshots, the probe output and the instrument that produced them are +// in the local capture folder; the fifteen first-pass screenshots are also in the shared folder. +// These rows are MEASUREMENTS: a value gunbc adopts differently (a rounded size, a row height, a +// link ink for a grey ground) is recorded by the adopting module and never written back here. +data issue_tracker_visual_capture_origin: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "issues.chromium.org" + } +} + +data issue_tracker_visual_capture_local: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: File + locator: "~/issue-tracker-shots" + } +} + +data issue_tracker_visual_capture_shared: ExternalAuthority = ExternalAuthority { + uri: Uri { + scheme: Https + locator: "drive.google.com/drive/folders/14rcDZQtJAHzrAYimoRNmm7rBS1HqV_9b?usp=sharing" + } +} + +type IssueTrackerVisualCapture { + captured_on: NonEmptyStr + origin: ExternalAuthority + local_copy: ExternalAuthority + shared_copy: ExternalAuthority + session: NonEmptyStr + viewport_width_px: Int + viewport_height_px: Int +} + +data issue_tracker_visual_capture: IssueTrackerVisualCapture = IssueTrackerVisualCapture { + captured_on: "2026-10-04", + origin: issue_tracker_visual_capture_origin, + local_copy: issue_tracker_visual_capture_local, + shared_copy: issue_tracker_visual_capture_shared, + session: "signed out", + viewport_width_px: 1440, + viewport_height_px: 1100, +} + +// THE MEASURED PALETTE, one row per scheme. Slots are named for where the colour was read: the +// content column behind the cards, a card, the comment field, body text, muted text, the +// hairline, the action colour and a card's leading bar. The light row reads +// the slots the 2026-09-22 token row already carries from that row, because the two captures +// agree and one colour has one spelling in this module. The dark row replaces the owner-directed +// dark tokens of 2026-09-23: it holds only what the dark capture shows, so a slot the capture +// did not reach (hover, highlight, error, pressed action) has no dark value here at all. +type IssueTrackerMeasuredPalette { + ground: Srgb8 + card: Srgb8 + field: Srgb8 + text: Srgb8 + text_muted: Srgb8 + hairline: Srgb8 + action: Srgb8 + leading_bar: Srgb8 +} + +data issue_tracker_measured_palette_light: IssueTrackerMeasuredPalette = IssueTrackerMeasuredPalette { + ground: issue_tracker_visual_tokens.background_subtle, + card: Srgb8 { r: 255, g: 255, b: 255 }, + field: issue_tracker_visual_tokens.background_subtle, + text: issue_tracker_visual_tokens.text_primary, + text_muted: issue_tracker_visual_tokens.text_secondary, + hairline: issue_tracker_visual_tokens.border_default, + action: issue_tracker_visual_tokens.action, + leading_bar: issue_tracker_visual_tokens.action, +} + +data issue_tracker_measured_palette_dark: IssueTrackerMeasuredPalette = IssueTrackerMeasuredPalette { + ground: Srgb8 { r: 24, g: 25, b: 27 }, + card: Srgb8 { r: 24, g: 25, b: 27 }, + field: Srgb8 { r: 36, g: 37, b: 40 }, + text: Srgb8 { r: 255, g: 255, b: 255 }, + text_muted: Srgb8 { r: 154, g: 160, b: 166 }, + hairline: Srgb8 { r: 60, g: 64, b: 67 }, action: Srgb8 { r: 138, g: 180, b: 248 }, - action_hover: Srgb8 { r: 174, g: 203, b: 250 }, - error: Srgb8 { r: 242, g: 139, b: 130 }, - font_body: "Roboto, Arial, sans-serif", - font_heading: "Google Sans, Roboto, Arial, sans-serif", + leading_bar: issue_tracker_visual_tokens.action, +} + +// THE MEASURED TYPE SCALE, one row per place the product sets text. Size is carried in tenths of +// a CSS px because the markdown heading computes to 18.2px (1.4em of the 13px body); line height +// is rounded to whole px (the card label computes to 15.23); tracking is hundredths of a px and is recorded only where the product sets it +// deliberately (the card label's 0.8px) -- the sub-pixel per-size tracking Material applies to +// every other row is not part of this model. The code face computes to a web-font alias for the +// platform monospace, so it is recorded as the generic family rather than under that alias. +type IssueTrackerMeasuredFace + = IssueTrackerFaceBody + | IssueTrackerFaceHeading + | IssueTrackerFaceMonospace + +type IssueTrackerMeasuredInk + = IssueTrackerInkText + | IssueTrackerInkMuted + | IssueTrackerInkAction + | IssueTrackerInkCode + +type IssueTrackerMeasuredCase + = IssueTrackerCaseAuthored + | IssueTrackerCaseUppercase + +type IssueTrackerMeasuredText { + face: IssueTrackerMeasuredFace + size_tenths_px: Int + line_height_px: Int + weight: Int + ink: IssueTrackerMeasuredInk + letter_case: IssueTrackerMeasuredCase + tracking_hundredths_px: Int +} + +type IssueTrackerMeasuredTypeScale { + body: IssueTrackerMeasuredText + field_label: IssueTrackerMeasuredText + card_label: IssueTrackerMeasuredText + section_heading: IssueTrackerMeasuredText + control: IssueTrackerMeasuredText + control_small: IssueTrackerMeasuredText + title: IssueTrackerMeasuredText + markdown_heading: IssueTrackerMeasuredText + chip: IssueTrackerMeasuredText + inline_code: IssueTrackerMeasuredText +} + +data issue_tracker_measured_type_scale: IssueTrackerMeasuredTypeScale = IssueTrackerMeasuredTypeScale { + body: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 130, line_height_px: 18, weight: 400, ink: IssueTrackerInkText, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + field_label: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 120, line_height_px: 16, weight: 500, ink: IssueTrackerInkMuted, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + card_label: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 110, line_height_px: 15, weight: 500, ink: IssueTrackerInkText, letter_case: IssueTrackerCaseUppercase, tracking_hundredths_px: 80 }, + section_heading: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 130, line_height_px: 18, weight: 700, ink: IssueTrackerInkMuted, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + control: IssueTrackerMeasuredText { face: IssueTrackerFaceHeading, size_tenths_px: 140, line_height_px: 20, weight: 500, ink: IssueTrackerInkAction, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + control_small: IssueTrackerMeasuredText { face: IssueTrackerFaceHeading, size_tenths_px: 130, line_height_px: 20, weight: 500, ink: IssueTrackerInkAction, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + title: IssueTrackerMeasuredText { face: IssueTrackerFaceHeading, size_tenths_px: 180, line_height_px: 24, weight: 400, ink: IssueTrackerInkText, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + markdown_heading: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 182, line_height_px: 24, weight: 500, ink: IssueTrackerInkText, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + chip: IssueTrackerMeasuredText { face: IssueTrackerFaceBody, size_tenths_px: 120, line_height_px: 22, weight: 400, ink: IssueTrackerInkText, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, + inline_code: IssueTrackerMeasuredText { face: IssueTrackerFaceMonospace, size_tenths_px: 130, line_height_px: 18, weight: 400, ink: IssueTrackerInkCode, letter_case: IssueTrackerCaseAuthored, tracking_hundredths_px: 0 }, +} + +// Inline code ink, read on the light capture only (the dark capture shows no inline code). +data issue_tracker_measured_code_ink_light: Srgb8 = Srgb8 { r: 24, g: 128, b: 56 } + +// THE MEASURED METRICS, in CSS px at device scale 1. One corner radius serves cards, chips, +// buttons, inputs and status; inline code has its own. Heights are drawn heights. The relation +// table's header is 32 and its rows 33; grid rows are 37. +type IssueTrackerMeasuredMetrics { + radius_px: Int + code_radius_px: Int + top_bar_height_px: Int + tab_height_px: Int + chip_height_px: Int + button_small_height_px: Int + button_height_px: Int + relation_header_height_px: Int + relation_row_height_px: Int + grid_row_height_px: Int +} + +data issue_tracker_measured_metrics: IssueTrackerMeasuredMetrics = IssueTrackerMeasuredMetrics { + radius_px: 4, + code_radius_px: 2, + top_bar_height_px: 48, + tab_height_px: 34, + chip_height_px: 22, + button_small_height_px: 24, + button_height_px: 32, + relation_header_height_px: 32, + relation_row_height_px: 33, + grid_row_height_px: 37, +} + +// CARD ANATOMY: card-coloured fill, a hairline border, the shared radius, uniform padding and an +// outer margin; the description and status-update cards thicken the leading border into a bar in +// the action colour. The label row (the uppercase card label, with author, time and an overflow +// menu on the product) is 36px tall. +type IssueTrackerMeasuredCard { + padding_px: Int + outer_margin_px: Int + border_px: Int + leading_bar_px: Int + label_row_height_px: Int +} + +data issue_tracker_measured_card: IssueTrackerMeasuredCard = IssueTrackerMeasuredCard { + padding_px: 16, + outer_margin_px: 8, + border_px: 1, + leading_bar_px: 4, + label_row_height_px: 36, +} + +// STATUS CHIP ANATOMY: the chip height tall on the shared radius, with 4px side padding and a 1px +// ring drawn inside its box. Open statuses pair a pale tint with a saturated ring; Fixed is +// card-coloured text on a fill in the muted text colour with a ring of the same colour; Obsolete +// and Duplicate are body text on a pale grey with a ring in the muted text colour. The plain +// chip (component path, ancestor chain) is the header chip row below. +type IssueTrackerMeasuredStatusChip { + side_padding_px: Int + ring_px: Int +} + +data issue_tracker_measured_status_chip: IssueTrackerMeasuredStatusChip = IssueTrackerMeasuredStatusChip { + side_padding_px: 4, + ring_px: 1, +} + +// HEADER CHIP ANATOMY: the component path and the ancestor chain above the title are each one chip +// of the chip height on the shared radius, with 6px side padding, no border, and a fill that is +// 6% of black over whatever is behind it. The ancestor chain lists every ancestor id through to +// the current issue inside the one chip, separated by a ">" glyph. +type IssueTrackerMeasuredHeaderChip { + side_padding_px: Int + fill_ink_pct: Int +} + +data issue_tracker_measured_header_chip: IssueTrackerMeasuredHeaderChip = IssueTrackerMeasuredHeaderChip { + side_padding_px: 6, + fill_ink_pct: 6, +} + +// PRIORITY MARK ANATOMY: the priority as body text in a box 23 wide and the chip height tall, +// inside a 2px border on the shared radius, with 2px of side padding inside the border (the rail's +// priority, probe t1-detail rail.priority). The border is transparent except on P1, where +// it is the action colour. P0 was not captured. +type IssueTrackerMeasuredPriorityMark { + width_px: Int + border_px: Int + side_padding_px: Int +} + +data issue_tracker_measured_priority_mark: IssueTrackerMeasuredPriorityMark = IssueTrackerMeasuredPriorityMark { + width_px: 23, + border_px: 2, + side_padding_px: 2, +} + +// RELATION TABLE ANATOMY (the Dependencies and Blocking tabs): the Parents, Blockers and Child +// issues sections are 32px apart inside one card, and each is the same table. Cells carry 4px +// side padding over a 1px hairline. Each level of nesting +// indents a child row's title by 24px (the product also draws an 18px disclosure chevron there, +// which no element here adopts). A closed row +// sits on the row-hover grey with its text in the muted colour (light capture only: the dark +// relation table was not captured). +type IssueTrackerMeasuredRelationTable { + section_gap_px: Int + cell_side_padding_px: Int + row_line_px: Int + level_indent_px: Int + closed_row_ground: Srgb8 +} + +data issue_tracker_measured_relation_table: IssueTrackerMeasuredRelationTable = IssueTrackerMeasuredRelationTable { + section_gap_px: 32, + cell_side_padding_px: 4, + row_line_px: 1, + level_indent_px: 24, + closed_row_ground: issue_tracker_visual_tokens.background_hover, +} + +// FIELD RAIL ANATOMY (the issue metadata sidebar, shots 01 and 15; probes t1-detail and +// t8-detail2): a column 331 wide including a 1px hairline on its leading edge, with no card. +// Each field is inset 19px from both sides and its rows are 36px apart (a 20px row between 8px +// margins; one row measured 37). The label sits in a column 23% of the row, clamped to 80..120px +// (the product's bv2-field-label-spacer), followed by 10px of padding; the value is set at +// 13/20 on one line, and a long identity is truncated rather than wrapped. A person is shown +// with a 12px circular avatar before the address. +type IssueTrackerMeasuredFieldRail { + width_px: Int + divider_px: Int + side_inset_px: Int + row_pitch_px: Int + label_column_pct: Int + label_column_min_px: Int + label_column_max_px: Int + label_gap_px: Int + value_line_height_px: Int + inline_avatar_px: Int +} + +data issue_tracker_measured_field_rail: IssueTrackerMeasuredFieldRail = IssueTrackerMeasuredFieldRail { + width_px: 331, + divider_px: 1, + side_inset_px: 19, + row_pitch_px: 36, + label_column_pct: 23, + label_column_min_px: 80, + label_column_max_px: 120, + label_gap_px: 10, + value_line_height_px: 20, + inline_avatar_px: 12, +} + +// BUTTON ANATOMY: three kinds on the shared radius, all in the heading face at weight 500. Filled +// is card-coloured text on the action colour; outlined is an action-coloured label on the card +// colour inside a hairline; text is the action-coloured label alone. Two sizes, the two button +// heights of the metrics row, with 10px and 12px side padding. A disabled button draws its label +// at 38% and its border or fill at 12% of one ink. +type IssueTrackerMeasuredButton { + small_side_padding_px: Int + side_padding_px: Int + border_px: Int + disabled_label_pct: Int + disabled_boundary_pct: Int +} + +data issue_tracker_measured_button: IssueTrackerMeasuredButton = IssueTrackerMeasuredButton { + small_side_padding_px: 10, + side_padding_px: 12, + border_px: 1, + disabled_label_pct: 38, + disabled_boundary_pct: 12, } // THE ISSUE'S ACTIONS AND THE PRODUCT'S BUTTON IDIOMS (owner directive 2026-09-24: the @@ -510,14 +813,18 @@ fn issue_action_wire(kind: IssueActionKind) -> String { // THE BUTTON IDIOMS: a contained primary button (filled action blue, the "Create Issue" shape) // for the surface's primary action, or a flat text button (action-blue label, no fill) for -// secondary actions. +// secondary actions. The outlined idiom (an action-blue label inside a hairline on the card +// colour) was measured on the 2026-10-04 capture: the issue header's "Hotlists" button and the +// relation sections' "Add" buttons. type IssueActionButtonIdiom = IssueButtonContainedPrimary + | IssueButtonOutlined | IssueButtonText fn issue_button_idiom_wire(idiom: IssueActionButtonIdiom) -> String { match idiom { IssueButtonContainedPrimary => "contained-primary" + IssueButtonOutlined => "outlined" IssueButtonText => "text" } } diff --git a/dag/extdeps/languages/css/properties.dag b/dag/extdeps/languages/css/properties.dag index 342d934b74e..9ec177cec2a 100644 --- a/dag/extdeps/languages/css/properties.dag +++ b/dag/extdeps/languages/css/properties.dag @@ -39,6 +39,7 @@ type CssPropertyName | BorderStyle | BoxShadow | BoxSizing + | ClipPath | Color | ColorScheme | Cursor @@ -53,6 +54,7 @@ type CssPropertyName | FontVariantNumeric | FontWeight | Gap + | GridArea | GridColumn | GridTemplateColumns | Height @@ -105,6 +107,7 @@ fn css_property_wire(p: CssPropertyName) -> String { BorderStyle => "border-style" BoxShadow => "box-shadow" BoxSizing => "box-sizing" + ClipPath => "clip-path" Color => "color" ColorScheme => "color-scheme" Cursor => "cursor" @@ -119,6 +122,7 @@ fn css_property_wire(p: CssPropertyName) -> String { FontVariantNumeric => "font-variant-numeric" FontWeight => "font-weight" Gap => "gap" + GridArea => "grid-area" GridColumn => "grid-column" GridTemplateColumns => "grid-template-columns" Height => "height" diff --git a/dag/gunbc/design/scale.dag b/dag/gunbc/design/scale.dag index fc0bd99c22b..6cf2a2a5ddf 100644 --- a/dag/gunbc/design/scale.dag +++ b/dag/gunbc/design/scale.dag @@ -1,6 +1,6 @@ module gunbc.design.scale -import std.types { Int, String, Bool } +import std.types { Int, String, Bool, List } import gunbc.design.material { CssDecl, decl, css_block } import extdeps.languages.css.values { CssLength, css_ch, css_px, css_length_wire, @@ -8,6 +8,7 @@ import extdeps.languages.css.values { CssGenericFamily, GenericSansSerif, GenericMonospace, GenericSystemUi, css_font_stack_wire, } +import extdeps.fonts.google_fonts { roboto_source } // Layer 2 token axes (design-register-library.md, the sibling axes to material/theme): spacing, // type, font stack, plus the dimension tokens (radius, border width, measure, fixed element @@ -110,11 +111,16 @@ data type_scale: List = [ text_10, text_11, text_12, text_13, text_14, text_15, text_18, text_24, ] -data font_sans: ScaleToken = font_token(name: "font-sans", families: [ - NamedFamily { name: "Roboto" }, +// The body stack. Its first family is the self-hosted Roboto face (ruling R2; gunbc.site.web_fonts +// serves it), named by the same upstream source row the served @font-face rule names +// (extdeps.fonts.google_fonts roboto_source), so this token cannot ask for a family the stylesheet +// does not declare. The fallbacks are the product's measured stack. +data font_sans_families: List = [ + NamedFamily { name: roboto_source.family }, NamedFamily { name: "Arial" }, GenericFamily { kind: GenericSansSerif }, -]) +] +data font_sans: ScaleToken = font_token(name: "font-sans", families: font_sans_families) data font_mono: ScaleToken = font_token(name: "font-mono", families: [ NamedFamily { name: "JetBrains Mono" }, GenericFamily { kind: GenericMonospace }, diff --git a/dag/gunbc/harness/harness_throughput.dag b/dag/gunbc/harness/harness_throughput.dag index 4c26ee23b23..f087b3d1cd6 100644 --- a/dag/gunbc/harness/harness_throughput.dag +++ b/dag/gunbc/harness/harness_throughput.dag @@ -273,6 +273,12 @@ fn ledger_read_line(line: String) -> LineRead { // FIRST GAP WINS, IN STREAM ORDER, and it decides for the whole ledger before any row is kept -- // the fail-closed half of the same fold. A ledger missing the rows it could not read is a shorter // run that looks complete. +// +// A READ STREAM WITH NO ROUND ROW IS AN OBSERVATION OF NO ROUND, NOT A GAP. It used to answer +// RoundsUnobserved, which made an attempt whose agent has not finished its first round look the +// same as one whose rounds were never captured. Whether a stream was captured at all is the +// caller's fact (gunbc.roadmap_workflow_progress reads the events only of a modeled attempt with a +// readable stream), so this reader states only what the stream it was given holds. fn harness_round_ledger_from_events(body: String) -> HarnessRoundLedger { let reads = map(split(s: body, delimiter: "\n"), l => ledger_read_line(line: l)) match fold(reads, init: none, f: (acc, r) => @@ -293,11 +299,7 @@ fn harness_round_ledger_from_events(body: String) -> HarnessRoundLedger { LineIsNotOurs => [] LineIsGap { cause: _ } => [] }) - if list_length(items: rows) == 0 { - RoundsUnobserved { reason: "no clocked round.usage row in the event stream" as NonEmptyStr } - } else { - RoundsObserved { rounds: rows } - } + RoundsObserved { rounds: rows } } } } @@ -316,7 +318,9 @@ fn harness_throughput_from_ledger(ledger: HarnessRoundLedger) -> HarnessThroughp match ledger { RoundsUnobserved { reason: r } => ThroughputUnobserved { reason: r } RoundsObserved { rounds: rs } => - if list_length(items: rs) < 2 { + if list_length(items: rs) == 0 { + ThroughputUnobserved { reason: "no clocked round yet" as NonEmptyStr } + } else if list_length(items: rs) < 2 { ThroughputUnobserved { reason: "one clocked round so far; an interval needs two" as NonEmptyStr } } else { let first = rs.first() @@ -389,17 +393,31 @@ type SegmentFold { segments: List } -fn round_segment_kind(now: Int?, last_measured: Int?, tool_calls: Int?) -> RoundSegmentKind { +// HOW FAR THE WORKTREE'S CHANGED-PATH COUNT ROSE OVER THE LAST MEASURED VALUE. It is an +// observation of the tree, not a count of writes: it is the one statement of "kind is a delta" +// above, read by the strip's kind below and by the build meter's changed-path marks +// (gunbc.roadmap.roadmap_build_meter), so the two cannot disagree about which rounds changed the +// tree. An unread count has no rise; a first measurement rises from an unmeasured tree, which is +// a fresh worktree with nothing changed. The result may be zero or negative (a commit lowers the +// count); only a positive rise is drawn. +fn round_changed_path_count_rise(now: Int?, last_measured: Int?) -> Int? { match now { - Absent => UnmeasuredRound + Absent => none Present { value: n } => match last_measured { - Absent => if n > 0 { WriteRound } else { round_segment_kind_unwritten(tool_calls: tool_calls) } - Present { value: m } => if n > m { WriteRound } else { round_segment_kind_unwritten(tool_calls: tool_calls) } + Absent => Present { value: n } + Present { value: m } => Present { value: n - m } } } } +fn round_segment_kind(now: Int?, last_measured: Int?, tool_calls: Int?) -> RoundSegmentKind { + match round_changed_path_count_rise(now: now, last_measured: last_measured) { + Absent => UnmeasuredRound + Present { value: rise } => if rise > 0 { WriteRound } else { round_segment_kind_unwritten(tool_calls: tool_calls) } + } +} + fn round_segment_kind_unwritten(tool_calls: Int?) -> RoundSegmentKind { match tool_calls { Absent => ReadRound diff --git a/dag/gunbc/roadmap/roadmap_belt_actuate.dag b/dag/gunbc/roadmap/roadmap_belt_actuate.dag index 07db3b251b1..2c13ca8bdfb 100644 --- a/dag/gunbc/roadmap/roadmap_belt_actuate.dag +++ b/dag/gunbc/roadmap/roadmap_belt_actuate.dag @@ -559,6 +559,8 @@ import gunbc.roadmap_session_placement { import gunbc.roadmap_workflow_command { WorkflowModeledStateStanding, WorkflowModeledStateObserved, WorkflowModeledStateAbsent, WorkflowModeledStateUnreadable, } +import gunbc.roadmap.roadmap_build_meter { attempt_view_revision } +import gunbc.site.element.build_meter { build_meter_wire_revision_member } import gunbc.roadmap_workflow_progress { AttemptWorktreeObservation, AttemptWorktreePresent, AttemptWorktreeAbsent, AttemptWorktreeUnobserved, attempt_worktree_observation_of, WorkflowAttemptEvidence, @@ -9290,6 +9292,9 @@ fn belt_workflow_attempts_observe_scoped( } } +// The 'attempt_view_revision' member names the compact work view that is current for this +// attempt (gunbc.roadmap.roadmap_build_meter attempt_view_revision), for a page that already shows +// it. Its staleness is the caller's revision comparison; a caller that made none passes none. // The wire join of the two authorities the client must never re-derive: the attempt's progress // members (gunbc.roadmap_workflow_progress, unchanged) plus the DECIDED activity view // (gunbc.roadmap_presentation) appended as one 'activity' member. The seam reads process and @@ -9320,12 +9325,14 @@ fn belt_workflow_attempt_presentation_json_for_instance( fn belt_workflow_attempt_presentation_json_with_tick_read( progress: WorkflowAttemptProgress, tick_read: BeltTickReceiptRead, + stale_nodes: List = [], ) -> JsonValue { json_object(members: concat( workflow_attempt_progress_json_members(progress: progress), [ json_kv(key: "activity", value: activity_view_json_value( view: activity_view_from_progress(progress: progress))), + json_kv(key: build_meter_wire_revision_member, value: json_string(s: attempt_view_revision(progress: progress, stale: any(stale_nodes, n => n == progress.node_id)))), json_kv( key: "verification_presentation", value: json_string( @@ -9371,6 +9378,7 @@ fn belt_tick_receipt_wire_json_for_instance( fn belt_workflow_attempts_json_value_for_instance( instance: HostDashboardInstance, observed: BeltWorkflowAttempts, + stale_nodes: List = [], ) -> JsonValue { let tick_read = belt_tick_receipt_read_for_instance(instance: instance) match observed { @@ -9392,6 +9400,7 @@ fn belt_workflow_attempts_json_value_for_instance( progress => belt_workflow_attempt_presentation_json_with_tick_read( progress: progress, tick_read: tick_read, + stale_nodes: stale_nodes, ), )), ), @@ -9412,10 +9421,12 @@ fn belt_workflow_attempts_json_value( fn belt_workflow_attempts_observed_json_for_instance( instance: HostDashboardInstance, observed: BeltWorkflowAttempts, + stale_nodes: List = [], ) -> String { serialize_json(v: belt_workflow_attempts_json_value_for_instance( instance: instance, observed: observed, + stale_nodes: stale_nodes, )) } diff --git a/dag/gunbc/roadmap/roadmap_build_meter.dag b/dag/gunbc/roadmap/roadmap_build_meter.dag new file mode 100644 index 00000000000..ce1a93f1ce1 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_build_meter.dag @@ -0,0 +1,517 @@ +module gunbc.roadmap.roadmap_build_meter + +import std.types { Bool, Int, List, String, NonEmptyStr } +import std.content_hash { content_hash_atom, content_hash_tagged, serialize_content_hash } +import extdeps.units.iso8601_calendar { iso8601_utc_text } +import extdeps.languages.json.emit { JsonValue, json_object, json_kv, json_string, json_int, json_bool, json_array, json_null, serialize_json } +import gunbc.harness.harness_throughput { + HarnessRoundLedger, RoundsObserved, RoundsUnobserved, RoundObservation, round_changed_path_count_rise, +} +import gunbc.harness.harness_tool { HarnessTool, RunCommandTool, ReadFileTool, WriteFileTool, EditFileTool } +import v2.std.algebra { filter } +import gunbc.roadmap_provider_events { + ProviderExecutionState, ProviderNotStarted, ProviderStarting, ProviderAwaitingSeat, ProviderRunning, + ProviderCompleted, ProviderFailed, ProviderBudgetSpent, ProviderEventsRefused, +} +import gunbc.roadmap_workflow_stage { + WorkflowSegment, WorkflowSegmentKind, WorkflowSegmentState, + WorkflowSegmentPending, WorkflowSegmentActive, WorkflowSegmentComplete, WorkflowSegmentFailed, + WorkflowSegmentRefused, WorkflowSegmentStopped, + EnvironmentWorkflowSegment, WorkspaceWorkflowSegment, AgentWorkflowSegment, SubmissionWorkflowSegment, + VerificationWorkflowSegment, PublicationWorkflowSegment, ReviewWorkflowSegment, GoalAuditWorkflowSegment, + workflow_segment_kind_key, workflow_segment_state_key, workflow_segment_detail_bounded, +} +import gunbc.workflow_reconcile { WorkflowFactKey } +import gunbc.roadmap.roadmap_submission { + CandidateHandoffState, HandoffAwaitingWorker, HandoffYielded, HandoffEvidenceRefused, HandoffDeclaredBlocked, + HandoffSubmitted, HandoffVerificationFailed, HandoffAwaitingIntegration, HandoffBlockedOnIntegration, + HandoffPublicationOwed, HandoffBlockedOnPublication, HandoffPublished, candidate_handoff_state_key, +} +import gunbc.roadmap_workflow_progress { WorkflowAttemptProgress, workflow_attempt_id } +import gunbc.roadmap_workflow_command { attempt_key_short_form } +import gunbc.roadmap.roadmap_event_log { EventPrincipal, event_principal_is_fabric } +import gunbc.roadmap_issue_location { issue_work_path, issue_work_event_path } +import gunbc.site.element.build_meter { + BuildMeterView, BuildMeterRound, BuildMeterMark, BuildMeterRun, BuildMeterWrite, BuildMeterRead, BuildMeterEdit, BuildMeterKind, + BuildMeterEvidence, BuildMeterTypedOperation, BuildMeterToolCallCount, BuildMeterChangedPathCountRise, + BuildMeterCoverage, BuildMeterNoRoundYet, BuildMeterRoundsRecorded, BuildMeterRoundsNotRecorded, + BuildMeterPose, BuildMeterWorking, BuildMeterWaitingForSeat, BuildMeterStopped, BuildMeterDone, BuildMeterIdle, + FailureKind, FailureRefused, FailureVerificationFailed, FailureActivityFailed, FailureBudgetSpent, FailureHandoffBlocked, + FailureDigest, FailureTarget, FailureStageGroup, failure_target_anchor, failure_target_round, failure_target_key, CompactWorkSummary, + build_meter_kind_key, build_meter_evidence_key, build_meter_pose_key, failure_kind_key, +} + +// THE COMPACT WORK SUMMARY OF AN ISSUE, derived here and only drawn by the BuildMeter element +// (gunbc.site.element.build_meter). Every field is read from one attempt's progress projection +// (gunbc.roadmap_workflow_progress WorkflowAttemptProgress) through the authority that owns it: +// stage kinds and states from gunbc.roadmap_workflow_stage, the changed-path rule from +// gunbc.harness.harness_throughput, the handoff state from gunbc.roadmap.roadmap_submission, the +// failure identity from gunbc.workflow_reconcile, the Work tab destination from +// gunbc.roadmap_issue_location. Nothing is classified by reading text, and no word is chosen here: +// the element says the words. +// +// WHAT A ROUND'S MARKS ARE, TODAY. The ledger records, per round, how many tool calls the model +// made and how many paths the worktree reported changed afterwards. So a round yields at most two +// marks, each labelled with its evidence: tool calls of unrecorded kind, drawn in the run lane, and +// the rise of the changed-path count, drawn in the write lane. Both are legacy inferences; when the +// producer that dispatches tools stamps their kinds, those arrive as typed operations and replace +// them, and the element already draws them. + +// ONE ATTEMPT'S FAILURE EVENTS, in stage order. An event is a stage obligation of the attempt +// that ended without completing, identified by its workflow obligation key (the same key the +// reconciliation reads), classified by its stage and state, with its recorded detail. A provider +// that stopped while no stage records the stop is the agent obligation's event; a blocked handoff +// that no stage records is the submission obligation's event. The Work tab lists these rows and +// the meter's digest names the first, so the link and its target are one list. +type FailureEvent { + key: WorkflowFactKey + kind: FailureKind + stage: WorkflowSegmentKind + detail: String +} + +fn failure_kind_of_segment(segment: WorkflowSegment) -> FailureKind? { + match segment.state { + WorkflowSegmentRefused => Present { value: FailureRefused } + WorkflowSegmentStopped => Present { value: FailureBudgetSpent } + WorkflowSegmentFailed => Present { value: match segment.kind { + VerificationWorkflowSegment => FailureVerificationFailed + SubmissionWorkflowSegment => FailureHandoffBlocked + PublicationWorkflowSegment => FailureHandoffBlocked + EnvironmentWorkflowSegment => FailureActivityFailed + WorkspaceWorkflowSegment => FailureActivityFailed + AgentWorkflowSegment => FailureActivityFailed + ReviewWorkflowSegment => FailureActivityFailed + GoalAuditWorkflowSegment => FailureActivityFailed + } } + WorkflowSegmentPending => none + WorkflowSegmentActive => none + WorkflowSegmentComplete => none + } +} + +fn failure_event_key(node_id: String, attempt_key: String, stage: WorkflowSegmentKind) -> WorkflowFactKey { + WorkflowFactKey { + workflow_id: workflow_attempt_id(node_id: node_id, attempt_key: attempt_key), + obligation_id: workflow_segment_kind_key(kind: stage), + } +} + +fn failure_kind_of_provider(provider: ProviderExecutionState) -> FailureKind? { + match provider { + ProviderFailed { activity: _, detail: _ } => Present { value: FailureActivityFailed } + ProviderBudgetSpent { activity: _, detail: _ } => Present { value: FailureBudgetSpent } + ProviderEventsRefused { activity: _, detail: _ } => Present { value: FailureRefused } + ProviderNotStarted => none + ProviderStarting { activity: _ } => none + ProviderAwaitingSeat { detail: _ } => none + ProviderRunning { activity: _ } => none + ProviderCompleted { activity: _ } => none + } +} + +fn provider_failure_detail(provider: ProviderExecutionState) -> String { + match provider { + ProviderFailed { activity: _, detail } => detail + ProviderBudgetSpent { activity: _, detail } => detail + ProviderEventsRefused { activity: _, detail } => detail + ProviderNotStarted => "" + ProviderStarting { activity: _ } => "" + ProviderAwaitingSeat { detail: _ } => "" + ProviderRunning { activity: _ } => "" + ProviderCompleted { activity: _ } => "" + } +} + +fn handoff_block_detail(handoff: CandidateHandoffState) -> String? { + match handoff { + HandoffYielded { reason } => Present { value: reason } + HandoffDeclaredBlocked { head_sha: _, readiness: _, explanation } => Present { value: explanation } + HandoffBlockedOnIntegration { head_sha: _, cause } => Present { value: cause } + HandoffBlockedOnPublication { head_sha: _, cause } => Present { value: cause } + HandoffAwaitingWorker => none + HandoffEvidenceRefused { reason: _ } => none + HandoffSubmitted { head_sha: _ } => none + HandoffVerificationFailed { head_sha: _, verdict_key: _ } => none + HandoffAwaitingIntegration { head_sha: _ } => none + HandoffPublicationOwed { head_sha: _ } => none + HandoffPublished { head_sha: _, pr_number: _, url: _ } => none + } +} + +fn attempt_failure_events_of( + node_id: String, + attempt_key: String, + segments: List, + provider: ProviderExecutionState, + handoff: CandidateHandoffState, +) -> List { + let staged = flat_map(segments, s => match failure_kind_of_segment(segment: s) { + Present { value: kind } => [FailureEvent { + key: failure_event_key(node_id: node_id, attempt_key: attempt_key, stage: s.kind), + kind: kind, + stage: s.kind, + detail: workflow_segment_detail_bounded(detail: s.detail), + }] + Absent => [] + }) + if count(staged) > 0 { staged } else { + match failure_kind_of_provider(provider: provider) { + Present { value: kind } => [FailureEvent { + key: failure_event_key(node_id: node_id, attempt_key: attempt_key, stage: AgentWorkflowSegment), + kind: kind, + stage: AgentWorkflowSegment, + detail: provider_failure_detail(provider: provider), + }] + Absent => match handoff_block_detail(handoff: handoff) { + Present { value: detail } => [FailureEvent { + key: failure_event_key(node_id: node_id, attempt_key: attempt_key, stage: SubmissionWorkflowSegment), + kind: FailureHandoffBlocked, + stage: SubmissionWorkflowSegment, + detail: detail, + }] + Absent => [] + } + } + } +} + +fn attempt_failure_events(progress: WorkflowAttemptProgress) -> List { + attempt_failure_events_of(node_id: progress.node_id, attempt_key: progress.attempt_key, segments: progress.segments, provider: progress.provider, handoff: progress.handoff) +} + +// The digest names the first event and counts them all. No authority ties a stage failure to a +// round today (a verification judges the candidate the whole run produced; a refusal belongs to a +// stage, not a round), so the round is absent and the element draws the failure at the working +// edge; a producer that records the round an operation was refused in supplies it here. +fn failure_digest_of(node_id: String, events: List) -> FailureDigest? { + match events.first() { + Absent => none + Present { value: primary } => Present { value: FailureDigest { + kind: primary.kind, + primary_event: primary.key, + event_count: count(events), + stage: primary.stage, + target: FailureStageGroup { stage: primary.stage, anchor: issue_work_event_path(node_id: node_id, workflow_id: primary.key.workflow_id, obligation_id: primary.key.obligation_id) }, + } } + } +} + +type BuildMeterRoundFold { + last_measured: Int? + rounds: List +} + +// TYPED OPERATIONS, BY ROUND. The producer that dispatches the harness's tools knows which tool it +// called (gunbc.harness.harness_tool HarnessTool, the one closed vocabulary), so a round whose +// operations it recorded carries one count per tool. Today's ledger does not carry them: the real +// path supplies none, and the dispatcher that stamps the kind on each tool event (the +// harness/typed-tool-event-kind change) is the named producer that will. A round with typed +// operations draws those and nothing else: its tool-call count and changed-path rise are proxies +// for the same operations, so drawing them too would count the round twice. +type BuildMeterToolCount { + tool: HarnessTool + count: Int +} + +type BuildMeterRoundOperations { + step: Int + operations: List +} + +fn build_meter_tool_kind(tool: HarnessTool) -> BuildMeterKind { + match tool { + RunCommandTool => BuildMeterRun + ReadFileTool => BuildMeterRead + WriteFileTool => BuildMeterWrite + EditFileTool => BuildMeterEdit + } +} + +fn build_meter_typed_marks(operations: List) -> List { + map(filter(operations, o => o.count > 0), o => BuildMeterMark { kind: build_meter_tool_kind(tool: o.tool), count: o.count, evidence: BuildMeterTypedOperation }) +} + +fn build_meter_proxy_marks(tool_calls: Int?, changed_path_rise: Int?) -> List { + concat( + match tool_calls { + Present { value: n } => if n > 0 { [BuildMeterMark { kind: BuildMeterRun, count: n, evidence: BuildMeterToolCallCount }] } else { [] } + Absent => [] + }, + match changed_path_rise { + Present { value: rise } => if rise > 0 { [BuildMeterMark { kind: BuildMeterWrite, count: rise, evidence: BuildMeterChangedPathCountRise }] } else { [] } + Absent => [] + }) +} + +fn build_meter_round_marks(typed: List?, tool_calls: Int?, changed_path_rise: Int?) -> List { + match typed { + Present { value: operations } => build_meter_typed_marks(operations: operations) + Absent => build_meter_proxy_marks(tool_calls: tool_calls, changed_path_rise: changed_path_rise) + } +} + +fn build_meter_typed_for(step: Int, typed: List) -> List? { + match filter(typed, t => t.step == step).first() { + Present { value: t } => Present { value: t.operations } + Absent => none + } +} + +fn build_meter_round_step(acc: BuildMeterRoundFold, r: RoundObservation, typed: List) -> BuildMeterRoundFold { + BuildMeterRoundFold { + last_measured: match r.files_changed { Present { value: n } => Present { value: n } Absent => acc.last_measured }, + rounds: acc.rounds.list_push(BuildMeterRound { + step: r.step, + marks: build_meter_round_marks(typed: build_meter_typed_for(step: r.step, typed: typed), tool_calls: r.tool_calls, changed_path_rise: round_changed_path_count_rise(now: r.files_changed, last_measured: acc.last_measured)), + input_tokens: r.input_tokens, + output_tokens: r.output_tokens, + }), + } +} + +// Coverage from the ledger: rounds with the last round's own recorded clock; a read ledger that +// holds none; or a ledger this instrumentation did not observe, with its reason. +fn build_meter_coverage(ledger: HarnessRoundLedger, typed: List = []) -> BuildMeterCoverage { + match ledger { + RoundsUnobserved { reason } => BuildMeterRoundsNotRecorded { reason: reason as String } + RoundsObserved { rounds } => match rounds.last() { + Absent => BuildMeterNoRoundYet + Present { value: last } => BuildMeterRoundsRecorded { + rounds: fold(rounds, init: BuildMeterRoundFold { last_measured: none, rounds: [] }, f: (acc, r) => build_meter_round_step(acc: acc, r: r, typed: typed)).rounds, + last_recorded_at: iso8601_utc_text(unix: last.epoch_ms / 1000), + } + } + } +} + +fn build_meter_is_published(handoff: CandidateHandoffState) -> Bool { + match handoff { + HandoffPublished { head_sha: _, pr_number: _, url: _ } => true + HandoffAwaitingWorker => false + HandoffYielded { reason: _ } => false + HandoffEvidenceRefused { reason: _ } => false + HandoffDeclaredBlocked { head_sha: _, readiness: _, explanation: _ } => false + HandoffSubmitted { head_sha: _ } => false + HandoffVerificationFailed { head_sha: _, verdict_key: _ } => false + HandoffAwaitingIntegration { head_sha: _ } => false + HandoffBlockedOnIntegration { head_sha: _, cause: _ } => false + HandoffPublicationOwed { head_sha: _ } => false + HandoffBlockedOnPublication { head_sha: _, cause: _ } => false + } +} + +// The pose comes from the outcome and observed activity, never from the marks. A failure stops +// the worker; a published candidate rests; otherwise the provider's observed state decides, and a +// provider that is neither running nor waiting leaves the worker idle. +fn build_meter_pose(failed: Bool, provider: ProviderExecutionState, handoff: CandidateHandoffState) -> BuildMeterPose { + if failed { + BuildMeterStopped + } else if build_meter_is_published(handoff: handoff) { + BuildMeterDone + } else { + match provider { + ProviderRunning { activity: _ } => BuildMeterWorking + ProviderStarting { activity: _ } => BuildMeterWorking + ProviderAwaitingSeat { detail: _ } => BuildMeterWaitingForSeat + ProviderNotStarted => BuildMeterIdle + ProviderCompleted { activity: _ } => BuildMeterIdle + ProviderFailed { activity: _, detail: _ } => BuildMeterStopped + ProviderBudgetSpent { activity: _, detail: _ } => BuildMeterStopped + ProviderEventsRefused { activity: _, detail: _ } => BuildMeterStopped + } + } +} + +fn build_meter_segment_is_complete(state: WorkflowSegmentState) -> Bool { + match state { + WorkflowSegmentComplete => true + WorkflowSegmentPending => false + WorkflowSegmentActive => false + WorkflowSegmentFailed => false + WorkflowSegmentRefused => false + WorkflowSegmentStopped => false + } +} + +// The current stage is the first one that has not completed; when every stage has, it is the +// last. +fn build_meter_current_segment(segments: List) -> WorkflowSegment? { + match fold(segments, init: none, f: (acc, s) => match acc { + Present { value: found } => Present { value: found } + Absent => if build_meter_segment_is_complete(state: s.state) { none } else { Present { value: s } } + }) { + Present { value: open } => Present { value: open } + Absent => segments.last() + } +} + +// THE ONE DERIVATION of the view. It takes the facts of an attempt the meter shows, so a claim +// about it supplies them directly; build_meter_view below is the same call on a real projection. +// Staleness is the presentation's revision comparison (gunbc.roadmap_presentation +// stale_attempt_nodes), passed in by the caller that made it. +fn build_meter_view_of( + node_id: String, + attempt_key: String, + segments: List, + rounds: HarnessRoundLedger, + provider: ProviderExecutionState, + handoff: CandidateHandoffState, + stale: Bool, + typed: List = [], +) -> BuildMeterView { + let failure = failure_digest_of(node_id: node_id, events: attempt_failure_events_of(node_id: node_id, attempt_key: attempt_key, segments: segments, provider: provider, handoff: handoff)) + let stage = build_meter_current_segment(segments: segments) + BuildMeterView { + attempt_key: attempt_key, + attempt_label: attempt_key_short_form(key: attempt_key), + stage: match stage { Present { value: s } => Present { value: s.kind } Absent => none }, + stage_state: match stage { Present { value: s } => Present { value: s.state } Absent => none }, + coverage: build_meter_coverage(ledger: rounds, typed: typed), + pose: build_meter_pose(failed: match failure { Present { value: _ } => true Absent => false }, provider: provider, handoff: handoff), + stale: stale, + failure: failure, + handoff: handoff, + destination: match failure { Present { value: d } => failure_target_anchor(target: d.target) Absent => issue_work_path(node_id: node_id) }, + } +} + +fn build_meter_view(progress: WorkflowAttemptProgress, stale: Bool) -> BuildMeterView { + build_meter_view_of( + node_id: progress.node_id, + attempt_key: progress.attempt_key, + segments: progress.segments, + rounds: progress.rounds, + provider: progress.provider, + handoff: progress.handoff, + stale: stale, + ) +} + +// THE REVISION'S PREIMAGE: the whole view and its subject, one member per field, so a value that +// can change what the element draws or says changes the revision. It is the canonical JSON of +// the view, hashed through the content-hash authority under this projection's own tag. +fn build_meter_mark_json(m: BuildMeterMark) -> JsonValue { + json_object(members: [ + json_kv(key: "kind", value: json_string(s: build_meter_kind_key(kind: m.kind))), + json_kv(key: "count", value: json_int(n: m.count)), + json_kv(key: "evidence", value: json_string(s: build_meter_evidence_key(evidence: m.evidence))), + ]) +} + +fn build_meter_round_json(r: BuildMeterRound) -> JsonValue { + json_object(members: [ + json_kv(key: "step", value: json_int(n: r.step)), + json_kv(key: "marks", value: json_array(elements: map(r.marks, m => build_meter_mark_json(m: m)))), + json_kv(key: "input_tokens", value: json_int(n: r.input_tokens)), + json_kv(key: "output_tokens", value: json_int(n: r.output_tokens)), + ]) +} + +fn build_meter_coverage_json(c: BuildMeterCoverage) -> JsonValue { + match c { + BuildMeterNoRoundYet => json_object(members: [json_kv(key: "coverage", value: json_string(s: "none-yet"))]) + BuildMeterRoundsNotRecorded { reason } => json_object(members: [ + json_kv(key: "coverage", value: json_string(s: "not-recorded")), + json_kv(key: "reason", value: json_string(s: reason)), + ]) + BuildMeterRoundsRecorded { rounds, last_recorded_at } => json_object(members: [ + json_kv(key: "coverage", value: json_string(s: "recorded")), + json_kv(key: "rounds", value: json_array(elements: map(rounds, r => build_meter_round_json(r: r)))), + json_kv(key: "last_recorded_at", value: json_string(s: last_recorded_at)), + ]) + } +} + +fn build_meter_failure_json(f: FailureDigest?) -> JsonValue { + match f { + Absent => json_null() + Present { value: d } => json_object(members: [ + json_kv(key: "kind", value: json_string(s: failure_kind_key(kind: d.kind))), + json_kv(key: "workflow_id", value: json_string(s: d.primary_event.workflow_id)), + json_kv(key: "obligation_id", value: json_string(s: d.primary_event.obligation_id)), + json_kv(key: "event_count", value: json_int(n: d.event_count)), + json_kv(key: "stage", value: json_string(s: workflow_segment_kind_key(kind: d.stage))), + json_kv(key: "target", value: json_string(s: failure_target_key(target: d.target))), + json_kv(key: "round", value: match failure_target_round(target: d.target) { Present { value: n } => json_int(n: n) Absent => json_null() }), + json_kv(key: "destination", value: json_string(s: failure_target_anchor(target: d.target))), + ]) + } +} + +fn build_meter_handoff_json(h: CandidateHandoffState) -> JsonValue { + json_object(members: [ + json_kv(key: "state", value: json_string(s: candidate_handoff_state_key(state: h))), + json_kv(key: "detail", value: match h { + HandoffPublished { head_sha: _, pr_number, url: _ } => json_int(n: pr_number) + HandoffVerificationFailed { head_sha: _, verdict_key } => json_string(s: verdict_key) + HandoffAwaitingWorker => json_null() + HandoffYielded { reason: _ } => json_null() + HandoffEvidenceRefused { reason: _ } => json_null() + HandoffDeclaredBlocked { head_sha: _, readiness: _, explanation: _ } => json_null() + HandoffSubmitted { head_sha: _ } => json_null() + HandoffAwaitingIntegration { head_sha: _ } => json_null() + HandoffBlockedOnIntegration { head_sha: _, cause: _ } => json_null() + HandoffPublicationOwed { head_sha: _ } => json_null() + HandoffBlockedOnPublication { head_sha: _, cause: _ } => json_null() + }), + ]) +} + +fn compact_work_preimage_json(subject: String, view: BuildMeterView) -> JsonValue { + json_object(members: [ + json_kv(key: "subject", value: json_string(s: subject)), + json_kv(key: "attempt_key", value: json_string(s: view.attempt_key)), + json_kv(key: "attempt_label", value: json_string(s: view.attempt_label)), + json_kv(key: "stage", value: match view.stage { Present { value: k } => json_string(s: workflow_segment_kind_key(kind: k)) Absent => json_null() }), + json_kv(key: "stage_state", value: match view.stage_state { Present { value: st } => json_string(s: workflow_segment_state_key(state: st)) Absent => json_null() }), + json_kv(key: "coverage", value: build_meter_coverage_json(c: view.coverage)), + json_kv(key: "pose", value: json_string(s: build_meter_pose_key(pose: view.pose))), + json_kv(key: "stale", value: json_bool(b: view.stale)), + json_kv(key: "failure", value: build_meter_failure_json(f: view.failure)), + json_kv(key: "handoff", value: build_meter_handoff_json(h: view.handoff)), + json_kv(key: "destination", value: json_string(s: view.destination)), + ]) +} + +data compact_work_revision_tag: NonEmptyStr = "gunbc.compact-work-summary/v1" as NonEmptyStr + +fn compact_work_revision(subject: String, view: BuildMeterView) -> String { + serialize_content_hash(hash: content_hash_tagged( + tag: compact_work_revision_tag, + payload: content_hash_atom(value: serialize_json(v: compact_work_preimage_json(subject: subject, view: view)) as NonEmptyStr), + )) as String +} + +fn compact_work_summary_of_view(subject: String, view: BuildMeterView) -> CompactWorkSummary { + CompactWorkSummary { subject: subject, view: view, revision: compact_work_revision(subject: subject, view: view) } +} + +fn compact_work_summary_of(progress: WorkflowAttemptProgress, stale: Bool) -> CompactWorkSummary { + compact_work_summary_of_view(subject: progress.node_id, view: build_meter_view(progress: progress, stale: stale)) +} + +// WHETHER AN ISSUE HAS A COMPACT WORK SUMMARY AT ALL is decided here, not by the page: an issue +// held by the Fabric workflow with an attempt has one, over its latest attempt (the caller's list +// is newest first); held by anyone else, unassigned, or with no attempt, it has none and nothing +// is placed. +fn compact_work_summary(holder: EventPrincipal?, attempts: List, stale: Bool) -> CompactWorkSummary? { + match holder { + Absent => none + Present { value: p } => + if !event_principal_is_fabric(p: p) { none } else { + match attempts.first() { + Absent => none + Present { value: latest } => Present { value: compact_work_summary_of(progress: latest, stale: stale) } + } + } + } +} + +// THE REVISION ON THE WORKFLOW WIRE. Every observation carries every attempt's revision; a page +// that shows a meter whose drawn revision differs asks its issue page for the rendering, so the +// browser never draws a meter itself. The wire and the page compute it with the same function +// from the same projection and the same staleness, so an unchanged attempt never asks. +fn attempt_view_revision(progress: WorkflowAttemptProgress, stale: Bool) -> String { + compact_work_summary_of(progress: progress, stale: stale).revision +} diff --git a/dag/gunbc/roadmap/roadmap_component.dag b/dag/gunbc/roadmap/roadmap_component.dag index 44ef823392c..19301f3405c 100644 --- a/dag/gunbc/roadmap/roadmap_component.dag +++ b/dag/gunbc/roadmap/roadmap_component.dag @@ -1,4 +1,6 @@ module gunbc.roadmap_component +import gunbc.site.element.build_meter { build_meter_subject_attribute, build_meter_revision_attribute, build_meter_wire_revision_member } +import gunbc.site.build_meter_client { build_meter_fresh_event } import gunbc.site.controls { ts_on_click, longest_label_len, dispatch_reserved_ch_for, theme_toggle_labels, theme_toggle_reserved_width, sound_toggle_labels, sound_toggle_reserved_width, js_local_storage_get, js_sound_muted_predicate, js_actx_member, js_oscillator_tick_body, js_oscillator_schedule_stmts, sound_preamble_statements, sound_toggle_bind_statements, js_document_element, theme_toggle_bind_statements, account_query_expr, account_swap_statements, dispatch_button_ch_gutter, theme_storage_key, theme_toggle_class, theme_toggle_auto_value, theme_toggle_label_prefix, sound_toggle_class, sound_toggle_note, sound_toggle_label_on, sound_toggle_label_off, sound_muted_storage_value, sound_sounding_storage_value, js_sound_muted_fn, js_audio_context_var, js_tick_fn, sound_preamble_note, js_theme_choice_fn, js_theme_apply_fn } import gunbc.roadmap_issue_sort { issue_sort_bind_statements } @@ -2658,7 +2660,140 @@ fn activity_paint_stmts() -> List { // paint minted one 'inspect' after every row the wire carried an attempt for — inline in the // issue table, and stacked one-per-member inside project and family rows on the old dashboard // lanes (fifteen deep). Execution history lives exclusively on the Work tab. +// THE COMPACT WORK SUMMARY REFRESH rides this same workflow observation: no second timer and no +// new route. Each wire attempt carries its current view revision +// (gunbc.roadmap.roadmap_build_meter attempt_view_revision). Where this page shows a summary for +// the attempt's node and the revision it drew differs, the page records the revision it now wants +// and asks its own address for the issue page, takes the summary the server rendered there, and +// hands that rendering to the meter (gunbc.site.build_meter_client build_meter_fresh_event). The +// meter takes its facts at once and decides when its drawing changes; the browser draws nothing +// the server did not render. +// +// A RENDERING IS TAKEN ONLY IF IT IS THE ONE STILL WANTED. If revision B is wanted while the read +// for A is outstanding, A's rendering, arriving late, carries revision A and is discarded; only a +// rendering whose own revision equals the latest wanted one replaces what is drawn. One read is +// made per distinct wanted revision, so a page and a wire that keep disagreeing cannot make a read +// per observation; a read that fails forgets that revision, and the next observation asks again. +data workflow_meter_wanted_attribute: String = "data-meter-wanted" +data workflow_meter_asked_attribute: String = "data-meter-asked" + +fn workflow_meter_body_attribute_expr(element: String, attribute: String) -> TsExpr { + ts_call( + callee: ts_member(object: ts_member(object: ts_ident(name: element), property: "firstElementChild"), property: "getAttribute"), + args: [ts_lit_str(value: attribute)]) +} + +fn workflow_meter_selector_expr() -> TsExpr { + ts_binop( + left: ts_binop( + left: ts_lit_str(value: join(["[", build_meter_subject_attribute, "=\""], "")), + op: "+", + right: ts_member(object: ts_ident(name: "attempt"), property: "node_id")), + op: "+", + right: ts_lit_str(value: "\"]")) +} + +fn workflow_meter_bm_call(method: String, args: List) -> TsStmt { + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "bm"), property: method), args: args)) +} + +fn workflow_meter_bm_attribute(name: String) -> TsExpr { + ts_call(callee: ts_member(object: ts_ident(name: "bm"), property: "getAttribute"), args: [ts_lit_str(value: name)]) +} + +fn workflow_meter_swap_stmts() -> List { + [ + ts_const(name: "fresh", value: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member(object: ts_new(callee: ts_ident(name: "DOMParser"), args: []), property: "parseFromString"), + args: [ts_ident(name: "page"), ts_lit_str(value: "text/html")]), + property: "querySelector"), + args: [ts_ident(name: "bmSelector")])), + ts_if( + cond: ts_binop( + left: ts_binop( + left: ts_binop(left: ts_ident(name: "fresh"), op: "&&", right: ts_member(object: ts_ident(name: "fresh"), property: "firstElementChild")), + op: "&&", + right: ts_member(object: ts_ident(name: "bm"), property: "firstElementChild")), + op: "&&", + right: ts_binop( + left: workflow_meter_body_attribute_expr(element: "fresh", attribute: build_meter_revision_attribute), + op: "===", + right: workflow_meter_bm_attribute(name: workflow_meter_wanted_attribute))), + then_stmt: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_ident(name: "bm"), property: "dispatchEvent"), + args: [ts_new(callee: ts_ident(name: "CustomEvent"), args: [ + ts_lit_str(value: build_meter_fresh_event), + ts_object(fields: [ts_field(key: "detail", value: ts_ident(name: "fresh"))]), + ])])), + ]), + else_stmt: none), + ] +} + +fn workflow_meter_refresh_stmts() -> List { + [ + ts_const(name: "bmSelector", value: workflow_meter_selector_expr()), + ts_const(name: "bm", value: ts_call( + callee: ts_member(object: ts_ident(name: "document"), property: "querySelector"), + args: [ts_ident(name: "bmSelector")])), + ts_const(name: "mk", value: ts_binop( + left: ts_member(object: ts_ident(name: "attempt"), property: build_meter_wire_revision_member), + op: "||", + right: ts_lit_str(value: ""))), + ts_if( + cond: ts_binop( + left: ts_binop( + left: ts_binop(left: ts_ident(name: "bm"), op: "&&", right: ts_ident(name: "mk")), + op: "&&", + right: ts_member(object: ts_ident(name: "bm"), property: "firstElementChild")), + op: "&&", + right: ts_binop( + left: workflow_meter_body_attribute_expr(element: "bm", attribute: build_meter_revision_attribute), + op: "!==", + right: ts_ident(name: "mk"))), + then_stmt: ts_block(stmts: [ + workflow_meter_bm_call(method: "setAttribute", args: [ts_lit_str(value: workflow_meter_wanted_attribute), ts_ident(name: "mk")]), + ts_if( + cond: ts_binop(left: workflow_meter_bm_attribute(name: workflow_meter_asked_attribute), op: "!==", right: ts_ident(name: "mk")), + then_stmt: ts_block(stmts: [ + workflow_meter_bm_call(method: "setAttribute", args: [ts_lit_str(value: workflow_meter_asked_attribute), ts_ident(name: "mk")]), + ts_expr_stmt(expr: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_member( + object: ts_call( + callee: ts_ident(name: "fetch"), + args: [ts_member(object: ts_member(object: ts_ident(name: "window"), property: "location"), property: "pathname")]), + property: "then"), + args: [ts_arrow(params: ["r"], body: ts_block(stmts: [ + ts_return(expr: Present { value: ts_call(callee: ts_member(object: ts_ident(name: "r"), property: "text"), args: []) }), + ]))]), + property: "then"), + args: [ts_arrow(params: ["page"], body: ts_block(stmts: workflow_meter_swap_stmts()))]), + property: "catch"), + args: [ts_arrow(params: [], body: ts_block(stmts: [ + ts_if( + cond: ts_binop(left: workflow_meter_bm_attribute(name: workflow_meter_asked_attribute), op: "===", right: ts_ident(name: "mk")), + then_stmt: ts_block(stmts: [workflow_meter_bm_call(method: "removeAttribute", args: [ts_lit_str(value: workflow_meter_asked_attribute)])]), + else_stmt: none), + ]))])), + ]), + else_stmt: none), + ]), + else_stmt: none), + ] +} + fn workflow_mark_attempt_stmts() -> List { + concat(workflow_meter_refresh_stmts(), workflow_mark_attempt_paint_stmts()) +} + +fn workflow_mark_attempt_paint_stmts() -> List { [ ts_const(name: "wr", value: ts_call( callee: ts_member( diff --git a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag index 0e25a0ef955..7e72e070ab6 100644 --- a/dag/gunbc/roadmap/roadmap_dashboard_instance.dag +++ b/dag/gunbc/roadmap/roadmap_dashboard_instance.dag @@ -312,6 +312,19 @@ fn dashboard_instance_child(root: FilePath, child: String) -> FilePath { join([root as String, "/", child], "") as FilePath } +// THE ROADMAP EVENT WORKTREE: where gunbc.roadmap.roadmap_event_carrier checks out the event +// branch, under the instance root. It is declared here, beside the instance's other roots, so the +// provision plan ensures the same path the carrier writes: the carrier's `git worktree add` cannot +// create it when the instance root is not writable by the service principal (srv1's /opt/gunbc is +// root-owned), and every issue page then answered 503. +fn dashboard_instance_roadmap_events_worktree_under(instance_root: FilePath) -> FilePath { + dashboard_instance_child(root: instance_root, child: "roadmap-events") +} + +fn dashboard_instance_roadmap_events_worktree(instance: HostDashboardInstance) -> FilePath { + dashboard_instance_roadmap_events_worktree_under(instance_root: instance.instance_root) +} + // THE INSTANCE'S TAILNET DOOR: the unix socket its door unit serves on and tailscale serve proxies // to. It sits in the instance root, which the service principal owns; who may open it is not what // the door relies on -- the handler is handed the kernel-attested peer of every connection @@ -1648,6 +1661,7 @@ fn dashboard_instance_provision_plan(instance: HostDashboardInstance) -> List DashboardEnsureDirectory { path: dashboard_instance_auth_store_root(instance: instance, store: s), })), [ diff --git a/dag/gunbc/roadmap/roadmap_event_carrier.dag b/dag/gunbc/roadmap/roadmap_event_carrier.dag index 243837cf70e..69d5d4020d5 100644 --- a/dag/gunbc/roadmap/roadmap_event_carrier.dag +++ b/dag/gunbc/roadmap/roadmap_event_carrier.dag @@ -38,7 +38,7 @@ import extdeps.git { shape_git_add_path_args, shape_git_commit_message_args, shape_git_ls_remote_heads_args, } import gunbc.roadmap_model { RoadmapNodeId } -import gunbc.roadmap_dashboard_instance { HostDashboardInstance, srv2_dashboard_preview_id } +import gunbc.roadmap_dashboard_instance { HostDashboardInstance, srv2_dashboard_preview_id, dashboard_instance_roadmap_events_worktree, dashboard_instance_roadmap_events_worktree_under } import gunbc.roadmap_dispatch_actuator { dispatch_capability_program } import gunbc.roadmap_execution_contract { GitWorkspaceCapability, AttemptStateDirectoryCapability } import gunbc.roadmap.roadmap_event_log { @@ -72,7 +72,7 @@ fn roadmap_event_carrier_layout_for_instance(instance: HostDashboardInstance) -> RoadmapEventCarrierLayout { remote: if instance.instance_id == srv2_dashboard_preview_id { concat(instance.instance_root as String, "/ui-preview-events.git") } else { roadmap_events_remote }, repo_root: instance.repo_root, - worktree: join([instance.instance_root as String, "/roadmap-events"], "") as FilePath, + worktree: dashboard_instance_roadmap_events_worktree(instance: instance), git_program: dispatch_capability_program(environment: instance.execution_environment, cap: GitWorkspaceCapability), directory_program: dispatch_capability_program(environment: instance.execution_environment, cap: AttemptStateDirectoryCapability), } @@ -82,7 +82,7 @@ fn roadmap_event_carrier_layout(repo_root: FilePath, instance_root: FilePath, gi RoadmapEventCarrierLayout { remote: roadmap_events_remote, repo_root: repo_root, - worktree: join([instance_root as String, "/roadmap-events"], "") as FilePath, + worktree: dashboard_instance_roadmap_events_worktree_under(instance_root: instance_root), git_program: git_program, directory_program: directory_program, } diff --git a/dag/gunbc/roadmap/roadmap_event_log.dag b/dag/gunbc/roadmap/roadmap_event_log.dag index c2a5e47f90b..dec0fe83936 100644 --- a/dag/gunbc/roadmap/roadmap_event_log.dag +++ b/dag/gunbc/roadmap/roadmap_event_log.dag @@ -59,6 +59,15 @@ type EventPrincipal = EventPrincipalAuthenticated { ref: PrincipalRef } | LegacyEmailPrincipal { email: NonEmptyStr, authentication_unestablished: Bool } +// Whether a principal is the Fabric workflow: the one test every surface uses to tell the +// workflow's assignment from a person's. +fn event_principal_is_fabric(p: EventPrincipal) -> Bool { + match p { + EventPrincipalAuthenticated { ref } => principal_ref_eq(a: ref, b: fabric_workflow_principal) + LegacyEmailPrincipal { email: _, authentication_unestablished: _ } => false + } +} + fn event_principal_label(p: EventPrincipal) -> String { match p { EventPrincipalAuthenticated { ref } => principal_ref_label(p: ref) diff --git a/dag/gunbc/roadmap/roadmap_hierarchy_view.dag b/dag/gunbc/roadmap/roadmap_hierarchy_view.dag new file mode 100644 index 00000000000..d16dbb87f08 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_hierarchy_view.dag @@ -0,0 +1,318 @@ +module gunbc.roadmap.roadmap_hierarchy_view + +import std.types { String, Int, Bool, List } +import v2.std.collection { Map } +import gunbc.roadmap_model { RoadmapNode, RoadmapEdge, RoadmapNodeId, roadmap_parent_ids } +import gunbc.roadmap_nesting { roadmap_memberships_of } +import gunbc.roadmap.roadmap_lineage { + LineageWalk, LineageRooted, LineageNoParent, LineageUndeclared, LineageCyclic, + lineage_walk_in, lineage_index, lineage_lookup, lineage_refusal_detail, +} +import gunbc.roadmap.roadmap_project { + RoadmapMembershipIndex, membership_index, node_project_membership_in, project_member_ids_in, + MembershipStandalone, MembershipMember, MembershipAmbiguous, +} +import gunbc.roadmap_priority { DerivedPriority, issue_priority_of } +import gunbc.roadmap_presentation { row_headline, dependency_cycle_touches } +import gunbc.roadmap_issue_status { IssueDisplayStatus, issue_display_status_is_closed } +import gunbc.roadmap_issue_location { issue_page_path } + +// WHERE A NODE SITS AND WHAT RELATES TO IT, ANSWERED ONCE. The issue page's chain, its relation +// sections and its rail all read this one projection, so no two of them can name a different +// parent, a different status or a different address for the same node. +// +// The vocabulary is fixed by the authority each relation is read from. PARENT and SUB-ISSUE are +// membership: the parent each row declares (gunbc.roadmap_model NodeParent, projected by +// gunbc.roadmap_nesting). BLOCKED BY and BLOCKING are dependency: the edges +// (gunbc.roadmap_model RoadmapEdge). Neither stands in for the other, and a node that is both a +// sub-issue and a blocker appears under both relations as the same NodePresentation -- the relation +// carries the meaning, the presentation does not change with it. + +// A STATUS IS DERIVED OR IT IS UNAVAILABLE, never guessed. The derived arm carries two facts the +// producer establishes separately: the status the node is shown with +// (gunbc.roadmap_presentation issue_display_status over its decided row, or +// omitted_node_display_status for a declared node the plan omits), and whether the node is +// CLOSED, which is its lifecycle's answer (gunbc.roadmap_presentation work_lifecycle_is_closed) +// and not a reading of the status word. They can differ: a done node whose standing cannot be +// observed is shown as Unknown and is still closed. Unavailable is reserved for a node whose +// state the producer cannot establish at all, and says why. +// +// A node the plan omits is shown by the authority that establishes it, never by its absence: +// Accepted when the acceptance authority lists it (closed), Withheld when the plan's withheld set +// names it (not closed: it is set aside, not finished). Unavailable is left for a node no +// authority establishes. +type NodeStatus + = NodeStatusDerived { status: IssueDisplayStatus, closed: Bool } + | NodeStatusAccepted + | NodeStatusWithheld + | NodeStatusUnavailable { reason: String } + +// A derived status with no lifecycle beside it: closed is what the status vocabulary says of that +// status (gunbc.roadmap_issue_status issue_display_status_is_closed). The specimen's planted nodes +// are built this way; a producer that holds a lifecycle states `closed` from it instead. +fn node_status_derived(status: IssueDisplayStatus) -> NodeStatus { + NodeStatusDerived { status: status, closed: issue_display_status_is_closed(status: status) } +} + +// A node whose state is unavailable is not known to be closed, so it is not shown as closed. +fn node_status_is_closed(status: NodeStatus) -> Bool { + match status { + NodeStatusDerived { status: _, closed } => closed + NodeStatusAccepted => true + NodeStatusWithheld => false + NodeStatusUnavailable { reason: _ } => false + } +} + +// ONE NODE AS SHOWN, wherever it is shown. `route` is the node's canonical address +// (gunbc.roadmap_issue_location issue_page_path); `slug` is its id. +type NodePresentation { + slug: String + title: String + status: NodeStatus + priority: DerivedPriority + route: String +} + +// THE MEMBERSHIP LINEAGE, root first. `ancestors` holds EVERY level from the admitted root down to +// the parent -- eliding the middle of a long chain is a rendering decision, so nothing is dropped +// here. A walk that does not reach the root keeps the lineage walk's own refusal +// (gunbc.roadmap.roadmap_lineage LineageNoParent, LineageUndeclared, LineageCyclic): a prefix of a +// chain is not a chain. +type NodeChain + = NodeChainRooted { ancestors: List, self_node: NodePresentation } + | NodeChainRefused { refusal: LineageWalk } + +// What a surface says in place of a refused chain: the lineage walk's own located reason. +fn node_chain_refusal_text(refusal: LineageWalk) -> String { + join(["project path unavailable: ", lineage_refusal_detail(w: refusal)], "") +} + +// A SUB-ISSUE ROW REFERS TO ITS OWN SUB-ISSUES BY COUNT; IT DOES NOT LOAD THEM. Loading them would +// put a status derivation for the whole subtree on every issue page -- at the root that is the +// whole tracker -- for rows the reader has not asked to see, and each sub-issue's own page already +// lists its sub-issues with their statuses. A row of a relation that is not Sub-issues carries no +// such reference at all. +type RelationRowChildren + = RowChildrenNotCarried + | RowChildrenExpandable { count: Int } + +type RelationKind + = RelationParent + | RelationSubIssues + | RelationBlockedBy + | RelationBlocking + +type RelationRow { node: NodePresentation, children: RelationRowChildren } + +// `undeclared` names the related ids no declared node answers to. They are not rows, because a row +// is a node as shown and there is no node to show; they are kept so the gap is stated. +type NodeRelation { kind: RelationKind, rows: List, undeclared: List } + +// Each view is built from its own authority alone: membership_view reads no edge, dependency_view +// reads no membership. +type MembershipView { parent: NodeRelation, sub_issues: NodeRelation } +type DependencyView { blocked_by: NodeRelation, blocking: NodeRelation, cycle: Bool } + +type IssueHierarchyView { chain: NodeChain, membership: MembershipView, dependency: DependencyView } + +// THE UNIVERSE a hierarchy is read over, indexed once: every node by id (first declaration wins, +// gunbc.roadmap.roadmap_lineage lineage_index) and the membership relation those nodes declare. +type HierarchyUniverse { + index: Map + memberships: RoadmapMembershipIndex + fuel: Int +} + +// A page shows plan rows and reads lineage over the declared rows; a row present in both is one +// node, so the declared rows join only where the plan has none. Without this a node declared in +// both lists would be its parent's sub-issue twice. +fn hierarchy_nodes(plan: List, declared: List) -> List { + let planned = lineage_index(nodes: plan) + concat(plan, filter(declared, n => match lineage_lookup(index: planned, id: n.node as String) { + Present { value: _ } => false + Absent => true + })) +} + +fn hierarchy_universe(nodes: List) -> HierarchyUniverse { + HierarchyUniverse { + index: lineage_index(nodes: nodes), + memberships: membership_index(memberships: roadmap_memberships_of(nodes: nodes)), + fuel: count(nodes) + 1, + } +} + +fn node_presentation(rn: RoadmapNode, universe: HierarchyUniverse, status_of: fn(RoadmapNode) -> NodeStatus) -> NodePresentation { + NodePresentation { + slug: rn.node as String, + title: row_headline(line: rn.line), + status: status_of(rn), + priority: issue_priority_of(node_id: rn.node as String, index: universe.memberships), + route: issue_page_path(node_id: rn.node as String), + } +} + +type RelationRowsBuild { rows: List, undeclared: List } + +fn relation_of(kind: RelationKind, ids: List, universe: HierarchyUniverse, status_of: fn(RoadmapNode) -> NodeStatus) -> NodeRelation { + let built = fold(ids, init: RelationRowsBuild { rows: [], undeclared: [] }, f: fn(acc, id) { + match lineage_lookup(index: universe.index, id: id) { + Absent => RelationRowsBuild { rows: acc.rows, undeclared: concat(acc.undeclared, [id]) } + Present { value: rn } => RelationRowsBuild { + rows: concat(acc.rows, [RelationRow { + node: node_presentation(rn: rn, universe: universe, status_of: status_of), + children: match kind { + RelationSubIssues => RowChildrenExpandable { count: count(project_member_ids_in(arc: id, index: universe.memberships)) } + RelationParent => RowChildrenNotCarried + RelationBlockedBy => RowChildrenNotCarried + RelationBlocking => RowChildrenNotCarried + }, + }]), + undeclared: acc.undeclared, + } + } + }) + NodeRelation { kind: kind, rows: built.rows, undeclared: built.undeclared } +} + +fn membership_view(node_id: String, universe: HierarchyUniverse, status_of: fn(RoadmapNode) -> NodeStatus) -> MembershipView { + MembershipView { + parent: relation_of(kind: RelationParent, ids: match node_project_membership_in(node_id: node_id, index: universe.memberships) { + MembershipStandalone => [] + MembershipMember { arc } => [arc] + MembershipAmbiguous { arcs } => arcs + }, universe: universe, status_of: status_of), + sub_issues: relation_of(kind: RelationSubIssues, ids: project_member_ids_in(arc: node_id, index: universe.memberships), universe: universe, status_of: status_of), + } +} + +// THE NODES THIS ONE BLOCKS: every edge naming it as the prerequisite, in edge order -- the +// converse read of gunbc.roadmap_model roadmap_parent_ids over the same edges. (RoadmapEdge spells +// its ends `child` and `parent`; they are the dependent and its prerequisite, not membership.) +fn dependency_blocking_ids(edges: List, node_id: String) -> List { + edges |> filter(e => (e.parent as String) == node_id) |> map(e => e.child as String) +} + +fn dependency_view(node: RoadmapNodeId, edges: List, universe: HierarchyUniverse, status_of: fn(RoadmapNode) -> NodeStatus) -> DependencyView { + DependencyView { + blocked_by: relation_of(kind: RelationBlockedBy, ids: roadmap_parent_ids(edges: edges, node: node) |> map(p => p as String), universe: universe, status_of: status_of), + blocking: relation_of(kind: RelationBlocking, ids: dependency_blocking_ids(edges: edges, node_id: node as String), universe: universe, status_of: status_of), + cycle: dependency_cycle_touches(node_id: node as String, edges: edges), + } +} + +type ChainBuild { nodes: List, missing: List } + +// The walk answers leaf to root; the chain reads root to leaf, so each id is prepended. +fn node_chain(node_id: String, universe: HierarchyUniverse, status_of: fn(RoadmapNode) -> NodeStatus) -> NodeChain { + let walk = lineage_walk_in(node_id: node_id, index: universe.index, fuel: universe.fuel) + match walk { + LineageNoParent { walked: _, at: _ } => NodeChainRefused { refusal: walk } + LineageUndeclared { walked: _, node_id: _ } => NodeChainRefused { refusal: walk } + LineageCyclic { node_id: _ } => NodeChainRefused { refusal: walk } + LineageRooted { ids } => { + let built = fold(ids, init: ChainBuild { nodes: [], missing: [] }, f: fn(acc, id) { + if id == node_id { + acc + } else { + match lineage_lookup(index: universe.index, id: id) { + Absent => ChainBuild { nodes: acc.nodes, missing: concat(acc.missing, [id]) } + Present { value: rn } => ChainBuild { nodes: concat([node_presentation(rn: rn, universe: universe, status_of: status_of)], acc.nodes), missing: acc.missing } + } + } + }) + match lineage_lookup(index: universe.index, id: node_id) { + Absent => NodeChainRefused { refusal: LineageUndeclared { walked: ids, node_id: node_id } } + Present { value: rn } => match built.missing.first() { + Present { value: gone } => NodeChainRefused { refusal: LineageUndeclared { walked: ids, node_id: gone } } + Absent => NodeChainRooted { ancestors: built.nodes, self_node: node_presentation(rn: rn, universe: universe, status_of: status_of) } + } + } + } + } +} + +fn issue_hierarchy_view(node: RoadmapNodeId, nodes: List, edges: List, status_of: fn(RoadmapNode) -> NodeStatus) -> IssueHierarchyView { + let universe = hierarchy_universe(nodes: nodes) + IssueHierarchyView { + chain: node_chain(node_id: node as String, universe: universe, status_of: status_of), + membership: membership_view(node_id: node as String, universe: universe, status_of: status_of), + dependency: dependency_view(node: node, edges: edges, universe: universe, status_of: status_of), + } +} + +// A RELATION'S COUNTS ARE A FUNCTION OF ITS ROWS, so a label and the table under it cannot +// disagree. `closed` counts the rows the producer established as closed, whatever status word they +// are shown with; `status_unavailable` counts the rows whose state the producer could not +// establish, which are neither closed nor open here. +type RelationSummary { total: Int, closed: Int, status_unavailable: Int } + +fn relation_summary(relation: NodeRelation) -> RelationSummary { + fold(relation.rows, init: RelationSummary { total: 0, closed: 0, status_unavailable: 0 }, f: fn(acc, row) { + match row.node.status { + NodeStatusUnavailable { reason: _ } => RelationSummary { total: acc.total + 1, closed: acc.closed, status_unavailable: acc.status_unavailable + 1 } + NodeStatusAccepted => RelationSummary { total: acc.total + 1, closed: acc.closed + 1, status_unavailable: acc.status_unavailable } + NodeStatusWithheld => RelationSummary { total: acc.total + 1, closed: acc.closed, status_unavailable: acc.status_unavailable } + NodeStatusDerived { status: _, closed } => RelationSummary { + total: acc.total + 1, + closed: if closed { acc.closed + 1 } else { acc.closed }, + status_unavailable: acc.status_unavailable, + } + } + }) +} + +// THE COUNT A LABEL SHOWS. Sub-issues read closed over total; every other relation reads its +// total. Closed-over-total is stated only when every row's state was established -- a count of closed +// rows beside rows of unknown state would understate it. The count is a value, so a heading that +// formats it and a label that prints it read the same decision. +type RelationCount + = RelationCountTotal { total: Int } + | RelationCountClosedOfTotal { closed: Int, total: Int } + +fn relation_count(relation: NodeRelation) -> RelationCount { + let summary = relation_summary(relation: relation) + match relation.kind { + RelationSubIssues => + if summary.status_unavailable == 0 { + RelationCountClosedOfTotal { closed: summary.closed, total: summary.total } + } else { + RelationCountTotal { total: summary.total } + } + RelationParent => RelationCountTotal { total: summary.total } + RelationBlockedBy => RelationCountTotal { total: summary.total } + RelationBlocking => RelationCountTotal { total: summary.total } + } +} + +fn relation_count_text(relation: NodeRelation) -> String { + match relation_count(relation: relation) { + RelationCountTotal { total } => to_string(value: total) + RelationCountClosedOfTotal { closed, total } => join([to_string(value: closed), "/", to_string(value: total)], "") + } +} + +fn relation_kind_label(kind: RelationKind) -> String { + match kind { + RelationParent => "Parent" + RelationSubIssues => "Sub-issues" + RelationBlockedBy => "Blocked by" + RelationBlocking => "Blocking" + } +} + +// What a relation with no rows says in place of its table. +fn relation_empty_text(kind: RelationKind) -> String { + match kind { + RelationParent => "No parent" + RelationSubIssues => "No sub-issues" + RelationBlockedBy => "Not blocked by any issue" + RelationBlocking => "Not blocking any issue" + } +} + +// What a relation says about an id it names that no declared node answers to. +fn relation_undeclared_text(kind: RelationKind) -> String { + join([relation_kind_label(kind: kind), " names no declared node"], "") +} diff --git a/dag/gunbc/roadmap/roadmap_issue_location.dag b/dag/gunbc/roadmap/roadmap_issue_location.dag new file mode 100644 index 00000000000..c41cb6d3571 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_issue_location.dag @@ -0,0 +1,33 @@ +module gunbc.roadmap_issue_location + +import std.types { String } + +// THE ONE ROUTE FAMILY OF A NODE: /issue/, with every further location inside it. A page that +// links to a node, posts a command for it, or anchors into its stream builds the path here, so a +// node has one address wherever it is shown. The served route table (gunbc.roadmap_serve +// roadmap_serve_own_route_specs) spells the same family as its match patterns. +data issue_route_prefix: String = "/issue/" + +fn issue_page_path(node_id: String) -> String { concat(issue_route_prefix, node_id) } + +fn issue_assign_path(node_id: String) -> String { concat(issue_page_path(node_id: node_id), "/assign") } + +fn issue_comment_path(node_id: String) -> String { concat(issue_page_path(node_id: node_id), "/comment") } + +fn issue_anchor_path(node_id: String, anchor: String) -> String { join([issue_page_path(node_id: node_id), "#", anchor], "") } + +// THE WORK TAB AND ITS FAILURE EVENTS. The Work tab's panel carries the tab's own anchor, and each +// failure event an attempt records is anchored by its workflow obligation identity (the workflow +// id and obligation id of gunbc.workflow_reconcile WorkflowFactKey), so a link to an event and the +// row that shows it are spelled by these two functions and nowhere else. +data issue_work_tab_anchor: String = "work" + +fn issue_work_path(node_id: String) -> String { issue_anchor_path(node_id: node_id, anchor: issue_work_tab_anchor) } + +fn issue_work_event_anchor(workflow_id: String, obligation_id: String) -> String { + join([issue_work_tab_anchor, "-", workflow_id, "-", obligation_id], "") +} + +fn issue_work_event_path(node_id: String, workflow_id: String, obligation_id: String) -> String { + issue_anchor_path(node_id: node_id, anchor: issue_work_event_anchor(workflow_id: workflow_id, obligation_id: obligation_id)) +} diff --git a/dag/gunbc/roadmap/roadmap_issue_status.dag b/dag/gunbc/roadmap/roadmap_issue_status.dag new file mode 100644 index 00000000000..c8ad5d70ff4 --- /dev/null +++ b/dag/gunbc/roadmap/roadmap_issue_status.dag @@ -0,0 +1,71 @@ +module gunbc.roadmap_issue_status + +import std.types { Bool, Int, List, String } + +// The status an issue is shown with, as a closed vocabulary. It is DERIVED and never stored: +// gunbc.roadmap_presentation issue_display_status projects it from a row's standing, lifecycle, +// scheduling and activity, and every surface that shows a status renders this value. The label +// and the sort rank are total matches over it, so a tenth status cannot be added without naming +// its word, its place in the order and whether it is closed. +type IssueDisplayStatus + = DisplayReady + | DisplayAssigned + | DisplayActive + | DisplayReview + | DisplayBlocked + | DisplayReturned + | DisplayDone + | DisplaySuperseded + | DisplayUnknown + +data issue_display_statuses: List = [ + DisplayReady, DisplayAssigned, DisplayActive, DisplayReview, DisplayBlocked, + DisplayReturned, DisplayDone, DisplaySuperseded, DisplayUnknown, +] + +fn issue_display_status_label(status: IssueDisplayStatus) -> String { + match status { + DisplayReady => "Ready" + DisplayAssigned => "Assigned" + DisplayActive => "Active" + DisplayReview => "Review" + DisplayBlocked => "Blocked" + DisplayReturned => "Returned" + DisplayDone => "Done" + DisplaySuperseded => "Superseded" + DisplayUnknown => "Unknown" + } +} + +// Owner 2026-10-04: descending status means Ready first, not lexical order. The same number +// drives the initial server order and interactive column sorting. +fn issue_display_status_sort_rank(status: IssueDisplayStatus) -> Int { + match status { + DisplayReady => 10 + DisplayActive => 9 + DisplayReview => 8 + DisplayAssigned => 7 + DisplayReturned => 6 + DisplayBlocked => 4 + DisplayUnknown => 3 + DisplayDone => 2 + DisplaySuperseded => 1 + } +} + +// CLOSED DESCRIBES THE NODE, NOT ANY EDGE INTO IT: a done or superseded node is closed. Whether a +// dependency on it is satisfied is the readiness predicate's question +// (gunbc.roadmap.roadmap_readiness), never this one's. +fn issue_display_status_is_closed(status: IssueDisplayStatus) -> Bool { + match status { + DisplayDone => true + DisplaySuperseded => true + DisplayReady => false + DisplayAssigned => false + DisplayActive => false + DisplayReview => false + DisplayBlocked => false + DisplayReturned => false + DisplayUnknown => false + } +} diff --git a/dag/gunbc/roadmap/roadmap_page.dag b/dag/gunbc/roadmap/roadmap_page.dag index 78241866201..fd183a395cb 100644 --- a/dag/gunbc/roadmap/roadmap_page.dag +++ b/dag/gunbc/roadmap/roadmap_page.dag @@ -6,6 +6,10 @@ import extdeps.languages.json.emit { serialize_json } import gunbc.site.status { status_badge, status_tone } import gunbc.roadmap_surface_style { workspace_issue_columns } import gunbc.roadmap_presentation { issue_status_sort_rank } +import gunbc.roadmap_issue_status { + DisplayReady, DisplayAssigned, DisplayActive, DisplayReview, DisplayBlocked, DisplayReturned, + DisplayDone, DisplaySuperseded, DisplayUnknown, issue_display_status_label, +} import gunbc.roadmap_priority { DerivedPriority, issue_priority_of, roadmap_priority_rank, workspace_priority_order_key, roadmap_priority_label, issue_priority_explanation } import gunbc.site.choice { site_choice_list, site_compact_choice, site_choice_input } import gunbc.site.dependency_graph { DependencyGraphItem, dependency_graph_interactive } @@ -28,7 +32,7 @@ import gunbc.roadmap_allocation_location { allocation_page_path } import gunbc.roadmap.roadmap_event_log { RoadmapNodeStanding, RoadmapStandings, StandingsObserved, StandingsUnobserved, standing_for_node, assignment_for_node, roadmap_issue_history_newest_first, roadmap_node_standing, roadmap_node_standing_key, roadmap_node_standing_text_naming, NodeOpen, NodeClaimed, NodeInProgress, NodeBlocked, NodeHandedBack, NodeForked, NodeHistoryIncomplete, RoadmapEventEnvelope, roadmap_event_kind_word } import gunbc.roadmap.roadmap_event_log { - EventPrincipal, EventPrincipalAuthenticated, LegacyEmailPrincipal, event_principal_label, + EventPrincipal, EventPrincipalAuthenticated, LegacyEmailPrincipal, event_principal_label, event_principal_is_fabric, IssueComment, IssueAssignmentProjection, roadmap_issue_comments, roadmap_issue_assignment, issue_comment_anchor, ResultReturned, AttemptResultOutcome, ResultPublished, ResultVerificationFailed, ResultHandedBack, attempt_result_outcome_word, attempt_result_outcome_text, roadmap_claim_return_to, RoadmapEventId, @@ -73,11 +77,15 @@ import gunbc.roadmap_authority { } import gunbc.roadmap_spawner { spawn_frontier, SpawnFrontier, doc_all_nodes, doc_all_edges, ReadinessGraph } -import gunbc.roadmap.roadmap_alignment { - AlignmentResolution, AlignmentReady, AlignmentUnresolved, - AlignmentLevel, RootPrinciplesLevel, ProjectIntentLevel, TaskContractLevel, - alignment_levels, alignment_resolve, alignment_base_cadence_state, alignment_refusal_reason, -} +import gunbc.roadmap.roadmap_hierarchy_view { + IssueHierarchyView, issue_hierarchy_view, hierarchy_nodes, + NodePresentation, NodeStatus, NodeStatusDerived, NodeStatusAccepted, NodeStatusWithheld, NodeStatusUnavailable, + NodeChain, NodeChainRooted, NodeChainRefused, + NodeRelation, RelationRow, RelationRowChildren, RowChildrenNotCarried, RowChildrenExpandable, + relation_summary, relation_count_text, relation_kind_label, relation_empty_text, relation_undeclared_text, + node_status_is_closed, node_chain_refusal_text, +} +import gunbc.roadmap_issue_location { issue_page_path, issue_assign_path, issue_comment_path, issue_anchor_path, issue_work_tab_anchor, issue_work_event_anchor } import gunbc.roadmap_component { activity_round_live_class, activity_round_remaining_class, round_strip_playback_ms } import gunbc.roadmap_component { roadmap_dispatch_asset_path, disclosure_mark_class, disclosure_mark_glyph, workflow_controls_class, dashboard_instance_class, dashboard_instance_label_class, activity_ledger_class, activity_obligation_class, activity_evidence_class, activity_evidence_row_class, activity_located_reason_class, activity_round_strip_class, activity_round_read_class, activity_round_write_class, activity_round_unmeasured_class, activity_round_reason_class, node_attempt_stale_class, stage_strip_class, stage_segment_class, stage_live_class, activity_throughput_class, activity_line_prefix_class } import gunbc.site.controls { sound_toggle_class, theme_toggle_class, theme_toggle_label_prefix, theme_toggle_auto_value } @@ -88,7 +96,7 @@ import gunbc.roadmap_launch_admission { launch_identity_instance_text, launch_identity_revision_hex, } import gunbc.roadmap_presentation { - WorkRowView, work_row_view, work_row_view_in, row_graph, row_headline, LaunchRowContext, + WorkRowView, work_row_view_in, row_graph, row_headline, LaunchRowContext, TaskBarView, task_bar_view_of, BarFillNone, BarFillStages, BarFillProject, BarFillClosed, row_filter_band_key, WorkLifecycle, OpenLifecycle, ReviewLifecycle, DoneLifecycle, SupersededLifecycle, @@ -116,10 +124,20 @@ import gunbc.roadmap_presentation { ProjectTreeEntry, ProjectTreeView, ProjectLeaf, ProjectNested, IssueTableRow, worker_alive_from_process, - IssueLinkView, IssueLinkPanel, issue_work_history_rounds, + issue_work_history_rounds, + issue_display_status, work_lifecycle_is_closed, omitted_node_display_status, + UnmetPrerequisitesAssessed, UnmetPrerequisitesNotAssessed, row_unmet_prerequisites, issue_status_label, issue_status_details, issue_first_open_kind, issue_wait_state_label, } import gunbc.roadmap_workflow_progress { WorkflowAttemptProgress } +import gunbc.roadmap.roadmap_build_meter { compact_work_summary, FailureEvent, attempt_failure_events } +import gunbc.site.element.build_meter { failure_event_words } +import gunbc.site.element.priority_mark { PriorityMark, priority_mark } +import gunbc.site.element.status_chip { status_chip_accepted_label, status_chip_withheld_label } +import gunbc.site.element.field_list { + FieldList, FieldRow, FieldValue, FieldText, FieldLinks, FieldLink, FieldAction, FieldIdentity, + FieldStatusControl, FieldIdentityControl, FieldWork, FieldAbsent, FieldPerson, field_list, +} import gunbc.roadmap_workflow_command { attempt_key_short_form } import extdeps.google.issue_tracker { issue_results_sort_key_wire, issue_results_sort_numeric, @@ -522,7 +540,7 @@ fn issue_assign_form_nodes(node_id: String, profiles: List, vi attr(name: "data-site-choice", value: "rich"), attr(name: "method", value: "post" ), attr(name: "enctype", value: "application/x-www-form-urlencoded" ), - attr(name: "action", value: join(["/issue/", node_id, "/assign"], "") ), + attr(name: "action", value: issue_assign_path(node_id: node_id) ), ], [ el_attrs("input", [ attr(name: "name", value: "key"), attr(name: "type", value: "hidden"), attr(name: "value", value: if command_context.operation == "" { "" } else { concat(command_context.operation, "-assign") }) ], []), el_attrs("input", [ attr(name: "name", value: "csrf"), attr(name: "type", value: "hidden"), attr(name: "value", value: command_context.csrf) ], []), @@ -603,13 +621,6 @@ fn issue_assign_suggestion_nodes(p: EventPrincipal, profiles: List Bool { - match p { - EventPrincipalAuthenticated { ref } => principal_ref_eq(a: ref, b: fabric_workflow_principal) - LegacyEmailPrincipal { email: _, authentication_unestablished: _ } => false - } -} - fn suggestion_kind_wire(p: EventPrincipal) -> String { if event_principal_is_fabric(p: p) { "workflow" } else { "human" } } @@ -1506,14 +1517,23 @@ fn issue_component_value(view: WorkRowView) -> String { if component == "" { issue_owner_value(view: view) } else { component } } -// THE PARENT FIELD renders DECOMPOSITION separately from dependency (owner ruling 2026-09-24): -// the panel's parent is the tree derivation's display link; top-level reads honestly as such. -// The named modeling gap — decomposition rides the primary-parent rule over dependency edges -// until decomposition edges land — is stated on the panel type, not papered over here. -fn issue_parent_value(panel: IssueLinkPanel) -> List { - match panel.parent { - Present { value: p } => issue_link_value(links: [ p ]) - Absent => [ txt("top-level") ] +// The rail always shows the component field; a row that declares neither a component nor an +// owner lane shows it as absent, with that reason. +fn issue_rail_component_value(view: WorkRowView) -> FieldValue { + let component = issue_component_value(view: view) + if component == "" { FieldAbsent { reason: "No component or owner lane is declared for this issue" } } else { FieldText { text: component, full: none } } +} + +// THE PARENT FIELD is the membership relation's Parent rows -- never a dependency edge. A node that +// is a member of nothing reads top-level; a parent the universe does not declare is named as such. +fn issue_parent_value(parent: NodeRelation) -> FieldValue { + if count(parent.rows) == 0 && count(parent.undeclared) == 0 { + FieldText { text: "top-level", full: none } + } else { + FieldLinks { + links: map(parent.rows, row => FieldLink { text: row.node.title, href: row.node.route, full: none }), + unlinked: if count(parent.undeclared) == 0 { [] } else { [join([relation_undeclared_text(kind: parent.kind), ": ", join(parent.undeclared, ", ")], "")] }, + } } } @@ -1611,16 +1631,11 @@ fn workspace_assignee_label(holder: EventPrincipal, profiles: List List { match col.kind { - IssueColumnPriority => [el_attrs("span", [ - attr(name: "class", value: "issue-priority"), - attr(name: "data-priority", value: to_string(roadmap_priority_rank(priority: priority.priority))), - attr(name: "title", value: issue_priority_explanation(value: priority)), - attr(name: "aria-label", value: issue_priority_explanation(value: priority)) - ], [txt(roadmap_priority_label(priority: priority.priority))])] + IssueColumnPriority => [priority_mark(input: PriorityMark { priority: priority.priority, explanation: Present { value: issue_priority_explanation(value: priority) } })] IssueColumnTitle => [ el_attrs("a", [ attr(name: "class", value: "issue-title-link" ), - attr(name: "href", value: join(["/issue/", view.node_id], "") ), + attr(name: "href", value: issue_page_path(node_id: view.node_id) ), ], inline_md_nodes(text: view.headline)) ] IssueColumnAssignee => [ el_attrs("button", [ @@ -1749,22 +1764,20 @@ fn issue_table_markup(rows: List, observation: ObservationImpactV // newest first) and lists every attempt — execution history lives here exclusively, never stacked // in the workspace rows (owner defect fix 2026-09-23). The page ships the same external client // asset the workspace ships; its tab chips are inert on surfaces that carry none. -fn issue_field_row(label: String, value: List) -> Fragment { - el_class("div", "issue-field", concat( - [ el_class("span", "issue-field-label", [ txt(label) ]) ], - [ el_class("span", "issue-field-value", value) ])) +// The rail's fields are rows of the FieldList element (gunbc.site.element.field_list); a field +// with no fact to show is no row, and a field whose fact is known to be missing is an absent +// value with the reason. +fn issue_rail_text_rows(label: String, value: String) -> List { + if value == "" { [] } else { [FieldRow { label: label, value: FieldText { text: value, full: none } }] } } -fn issue_field_text_row(label: String, value: String) -> Fragment { - issue_field_row(label: label, value: if value == "" { [] } else { [ txt(value) ] }) -} - -fn issue_link_value(links: List) -> List { - map(links, l => - el_attrs("a", [ - attr(name: "class", value: "issue-link" ), - attr(name: "href", value: join(["/issue/", l.node_id], "") ), - ], [ txt(l.headline) ])) +// A related id that names no declared node is stated, never dropped and never dressed as a row. +fn issue_relation_undeclared_nodes(relation: NodeRelation) -> List { + if count(relation.undeclared) == 0 { + [] + } else { + [ el_class("span", "issue-panel-empty", [ txt(join([relation_undeclared_text(kind: relation.kind), ": ", join(relation.undeclared, ", ")], "")) ]) ] + } } // The Work tab's history rows ride the landed taskbar grammar: one bar per clocked round, the @@ -1794,14 +1807,30 @@ fn issue_work_history_row_node(seg: RoundSegment) -> Fragment { // the attempt's short key and its decided outcome sentence — the latest attempt's summary is the // table row's whole claim about execution; the tab is where a node's N attempts are enumerated. fn issue_attempt_history_row_node(progress: WorkflowAttemptProgress) -> Fragment { - el_attrs("div", [ - attr(name: "class", value: "taskbar work-attempt-row" ), - attr(name: "data-attempt", value: attempt_key_short_form(key: progress.attempt_key) ), - ], [ - el_class("div", "taskbar-face", concat( - [ el_class("span", "work-history-step", [ txt(attempt_key_short_form(key: progress.attempt_key)) ]) ], - [ el_class("span", "taskbar-title", [ txt(activity_summary_line(a: activity_view_from_progress(progress: progress))) ]) ])) - ]) + el_class("div", "work-attempt", concat([ + el_attrs("div", [ + attr(name: "class", value: "taskbar work-attempt-row" ), + attr(name: "data-attempt", value: attempt_key_short_form(key: progress.attempt_key) ), + ], [ + el_class("div", "taskbar-face", concat( + [ el_class("span", "work-history-step", [ txt(attempt_key_short_form(key: progress.attempt_key)) ]) ], + [ el_class("span", "taskbar-title", [ txt(activity_summary_line(a: activity_view_from_progress(progress: progress))) ]) ])) + ]), + ], issue_attempt_failure_nodes(events: attempt_failure_events(progress: progress)))) +} + +// AN ATTEMPT'S FAILURE EVENTS, each its own row anchored by its workflow obligation identity +// (gunbc.roadmap_issue_location issue_work_event_anchor), so the compact work summary's link opens +// the Work tab at the event it names. The rows are the producer's list +// (gunbc.roadmap.roadmap_build_meter attempt_failure_events), worded by the element's one wording. +fn issue_attempt_failure_nodes(events: List) -> List { + if count(events) == 0 { [] } else { + [ el_class("ol", "work-attempt-failures", map(events, e => + el_attrs("li", [ + attr(name: "class", value: "work-attempt-failure"), + attr(name: "id", value: issue_work_event_anchor(workflow_id: e.key.workflow_id, obligation_id: e.key.obligation_id)), + ], [ txt(if e.detail == "" { failure_event_words(kind: e.kind, stage: e.stage) } else { join([failure_event_words(kind: e.kind, stage: e.stage), ": ", e.detail], "") }) ]))) ] + } } // THE EVENT HISTORY (owner ruling 2026-09-24: the Work tab carries the issue's execution @@ -1950,7 +1979,7 @@ fn issue_comment_head_node(node_id: String, comments: List, c: Iss concat( [ el_attrs("a", [ attr(name: "class", value: "issue-comment-ref" ), - attr(name: "href", value: join(["/issue/", node_id, "#", issue_comment_anchor(ordinal: c.ordinal)], "") ), + attr(name: "href", value: issue_anchor_path(node_id: node_id, anchor: issue_comment_anchor(ordinal: c.ordinal)) ), ], [ txt(join(["#comment-", to_string(value: c.ordinal)], "")) ]) ], concat( [ el_class("span", "issue-comment-at", [ txt(comment_time_text(c: c)) ]) ], @@ -2132,7 +2161,7 @@ fn issue_comment_composer_node(node_id: String, expected_revision: String, profi attr(name: "class", value: "issue-comment-form" ), attr(name: "method", value: "post" ), attr(name: "enctype", value: "application/x-www-form-urlencoded" ), - attr(name: "action", value: join(["/issue/", node_id, "/comment"], "") ), + attr(name: "action", value: issue_comment_path(node_id: node_id) ), ], [ el_attrs("input", [ attr(name: "name", value: "key" ), attr(name: "type", value: "hidden" ), attr(name: "value", value: if command_context.operation == "" { "" } else { concat(command_context.operation, "-comment") } ) ], []), el_attrs("input", [ attr(name: "name", value: "csrf" ), attr(name: "type", value: "hidden" ), attr(name: "value", value: command_context.csrf ) ], []), @@ -2225,79 +2254,25 @@ fn issue_head_chip_nodes(view: WorkRowView) -> List { status_chip_nodes(view: view) } -// THE PROJECT PATH BREADCRUMB: one navigation answer per issue, rendered directly above the page -// body. The ONLY derivation named is alignment_resolve -- the same resolution the dispatch gate -// consumes -- never a second path answer folded out of the raw memberships. The ready arm renders -// the chain's levels ROOT-TO-LEAF: each project level links to its own /issue/ page, the -// task level is the current page and renders as plain text (no self-link), and the -// root-principles pointer is skipped (it is a pointer to gunbc.design_document, not a page). The -// unresolved arm renders the typed refusal itself -- a partial path is never dressed as a complete -// one. -type IssueBreadcrumbCrumb - = IssueBreadcrumbProject { node_id: String, purpose: String, boundary: String } - | IssueBreadcrumbTask { node_id: String, purpose: String, boundary: String } - -type IssueBreadcrumb - = IssueBreadcrumbPath { crumbs: List } - | IssueBreadcrumbRefused { reason: String } - -// THE CRUMBS from the chain's levels: the root-principles level contributes no crumb (a pointer, -// not a page); every other level contributes one crumb carrying its purpose and boundary. -fn issue_breadcrumb_crumbs(levels: List) -> List { - fold(levels, init: [] as List, f: fn(acc, level) { - match level { - RootPrinciplesLevel => acc - ProjectIntentLevel { link } => concat(acc, [IssueBreadcrumbProject { node_id: link.node_id, purpose: link.purpose, boundary: link.boundary }]) - TaskContractLevel { link } => concat(acc, [IssueBreadcrumbTask { node_id: link.node_id, purpose: link.purpose, boundary: link.boundary }]) - } - }) -} - -fn issue_breadcrumb_of(resolution: AlignmentResolution) -> IssueBreadcrumb { - match resolution { - AlignmentReady { chain, due_levels: _, rendered_prompt: _ } => - IssueBreadcrumbPath { crumbs: issue_breadcrumb_crumbs(levels: alignment_levels(chain: chain)) } - AlignmentUnresolved { cause } => - IssueBreadcrumbRefused { reason: alignment_refusal_reason(cause: cause) } - } +// THE CHAIN BREADCRUMB: one navigation answer per issue, rendered directly above the page body. It +// renders the NodeChain the hierarchy view derived from the one parent walk +// (gunbc.roadmap.roadmap_hierarchy_view node_chain) and derives nothing itself. Every ancestor is a +// node like any other and links to its canonical route; the current node is the page and renders +// as plain text. A refused walk renders the walk's own refusal -- a partial path is never dressed +// as a complete one. +fn issue_breadcrumb_ancestor_node(node: NodePresentation) -> Fragment { + el_attrs("a", [ + attr(name: "href", value: node.route), + attr(name: "title", value: node.slug), + ], [ txt(node.title) ]) } -fn issue_breadcrumb_crumb_node(crumb: IssueBreadcrumbCrumb) -> Fragment { - match crumb { - IssueBreadcrumbProject { node_id, purpose, boundary } => - el_attrs("a", [ - attr(name: "href", value: join(["/issue/", node_id], "")), - attr(name: "title", value: boundary), - ], [ txt(purpose) ]) - IssueBreadcrumbTask { node_id: _, purpose, boundary } => - el_attrs("span", [ - attr(name: "class", value: "issue-breadcrumb-here"), - attr(name: "aria-current", value: "page"), - attr(name: "title", value: boundary), - ], [ txt(purpose) ]) - } -} - -// THE CRUMB LIST'S SEPARATOR LAW: " › " BETWEEN crumbs only -- never leading, never trailing -- -// so the single-crumb path and the refusal arm emit exactly their own text and nothing else. -fn issue_breadcrumb_nodes(breadcrumb: IssueBreadcrumb) -> List { - match breadcrumb { - IssueBreadcrumbPath { crumbs } => { - let spaced = fold(crumbs, init: { out: [] as List, first: true }, f: fn(acc, crumb) { - { - out: if acc.first { - concat(acc.out, [issue_breadcrumb_crumb_node(crumb: crumb)]) - } else { - concat(acc.out, [txt(" › "), issue_breadcrumb_crumb_node(crumb: crumb)]) - }, - first: false, - } - }) - spaced.out - } - IssueBreadcrumbRefused { reason } => - [ el_class("span", "issue-breadcrumb-refused", [ txt(join(["project path unavailable: ", reason], "")) ]) ] - } +fn issue_breadcrumb_self_node(node: NodePresentation) -> Fragment { + el_attrs("span", [ + attr(name: "class", value: "issue-breadcrumb-here"), + attr(name: "aria-current", value: "page"), + attr(name: "title", value: node.slug), + ], [ txt(node.title) ]) } fn issue_breadcrumb_short_label(label: String) -> String { @@ -2306,55 +2281,47 @@ fn issue_breadcrumb_short_label(label: String) -> String { } } -fn issue_breadcrumb_compact_labels(crumbs: List) -> List { - let ancestors = fold(crumbs, init: [] as List, f: (acc, c) => match c { - IssueBreadcrumbProject { node_id: _, purpose, boundary: _ } => concat(acc, [purpose]) - IssueBreadcrumbTask { node_id: _, purpose: _, boundary: _ } => acc - }) - let ends = fold(ancestors, init: { first: "", last: "", n: 0 }, f: (acc, label) => { - first: if acc.n == 0 { label } else { acc.first }, last: label, n: acc.n + 1 +// ELISION IS THIS RENDERING'S DECISION, not the chain's: the collapsed summary names the first and +// last ancestor and counts the levels between them; the tree beneath it lists every level. +fn issue_breadcrumb_compact_labels(ancestors: List) -> List { + let ends = fold(ancestors, init: { first: "", last: "", n: 0 }, f: (acc, node) => { + first: if acc.n == 0 { node.title } else { acc.first }, last: node.title, n: acc.n + 1 }) if ends.n == 0 { [] } else if ends.n == 1 { [ends.first] } else if ends.n == 2 { [ends.first, "›", ends.last] } else { [ends.first, join(["› … ", to_string(ends.n - 2), " ›"], ""), ends.last] } } -fn issue_breadcrumb_tree(crumbs: List) -> List { - let reversed = fold(crumbs, init: [] as List, f: (acc, crumb) => concat([crumb], acc)) - fold(reversed, init: [] as List, f: (children, crumb) => - [el("li", concat([issue_breadcrumb_crumb_node(crumb: crumb)], if count(children) == 0 { [] } else { [el("ol", children)] }))]) +fn issue_breadcrumb_tree(ancestors: List, self_node: NodePresentation) -> List { + let root_first = concat(map(ancestors, node => issue_breadcrumb_ancestor_node(node: node)), [issue_breadcrumb_self_node(node: self_node)]) + let leaf_first = fold(root_first, init: [] as List, f: (levels, level) => concat([level], levels)) + fold(leaf_first, init: [] as List, f: (children, level) => + [el("li", concat([level], if count(children) == 0 { [] } else { [el("ol", children)] }))]) } -fn issue_alignment_breadcrumb_node(resolution: AlignmentResolution) -> Fragment { - let breadcrumb = issue_breadcrumb_of(resolution: resolution) - el_attrs("nav", [attr(name: "class", value: "issue-breadcrumb"), attr(name: "aria-label", value: "Issue hierarchy")], match breadcrumb { - IssueBreadcrumbRefused { reason: _ } => issue_breadcrumb_nodes(breadcrumb: breadcrumb) - IssueBreadcrumbPath { crumbs } => { - let labels = issue_breadcrumb_compact_labels(crumbs: crumbs) - if count(labels) == 0 { [] } else if count(crumbs) == 1 && all(labels, label => string_length(s: label) <= 44) { - [el_class("ol", "issue-path-tree", issue_breadcrumb_tree(crumbs: crumbs))] - } else { +fn issue_chain_breadcrumb_node(chain: NodeChain) -> Fragment { + el_attrs("nav", [attr(name: "class", value: "issue-breadcrumb"), attr(name: "aria-label", value: "Issue hierarchy")], match chain { + NodeChainRefused { refusal } => + [ el_class("span", "issue-breadcrumb-refused", [ txt(node_chain_refusal_text(refusal: refusal)) ]) ] + NodeChainRooted { ancestors, self_node } => { + let labels = issue_breadcrumb_compact_labels(ancestors: ancestors) + if count(labels) == 0 { [] } else { [el_class("details", "issue-path-disclosure", [ el_attrs("summary", [attr(name: "aria-label", value: "Expand issue hierarchy")], map(labels, label => el_attrs("span", [attr(name: "class", value: "issue-path-label"), attr(name: "title", value: label)], [txt(issue_breadcrumb_short_label(label: label))]))), - el_class("ol", "issue-path-tree", issue_breadcrumb_tree(crumbs: crumbs)), + el_class("ol", "issue-path-tree", issue_breadcrumb_tree(ancestors: ancestors, self_node: self_node)), ])] } } }) } -fn issue_detail_page(view: WorkRowView, panel: IssueLinkPanel, observation: ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> Fragment { - issue_detail_page_with_alignment(view: view, panel: panel, observation: observation, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context, - alignment: alignment_resolve(node_id: view.node_id, turns_elapsed: 1, state: alignment_base_cadence_state())) -} - -// The page consumes the resolved project path; the authority-bound entry above supplies it. -fn issue_detail_page_with_alignment(view: WorkRowView, panel: IssueLinkPanel, observation: ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }, alignment: AlignmentResolution) -> Fragment { +// The page consumes the hierarchy view its entry derived; it reads no node list and no edge itself. +fn issue_detail_page(view: WorkRowView, hierarchy: IssueHierarchyView, observation: ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> Fragment { el_attrs("main", [ attr(name: "class", value: "issue-detail" ), attr(name: "data-tab", value: "comments" ), ], [ - issue_alignment_breadcrumb_node(resolution: alignment), + issue_chain_breadcrumb_node(chain: hierarchy.chain), el_class("div", "issue-detail-head", concat( [ el_class("span", "issue-id", [ txt(view.node_id) ]) ], concat( @@ -2369,11 +2336,11 @@ fn issue_detail_page_with_alignment(view: WorkRowView, panel: IssueLinkPanel, ob el_class("div", "issue-body", [ el_class("div", "issue-panels", [ el_class("section", "panel-comments", issue_comments_tab_nodes(view: view, events: events, expected_revision: expected_revision, command_context: command_context)), - el_class("section", "panel-dependencies", issue_dependencies_tab_nodes(view: view, panel: panel)), - el_class("section", "panel-work", issue_work_tab_nodes(view: view, attempts: attempts, events: events)), + el_class("section", "panel-dependencies", issue_dependencies_tab_nodes(view: view, hierarchy: hierarchy)), + el_attrs("section", [attr(name: "class", value: "panel-work"), attr(name: "id", value: issue_work_tab_anchor)], issue_work_tab_nodes(view: view, attempts: attempts, events: events)), el_class("section", "panel-history", issue_history_tab_nodes(events: events, profiles: view.profiles)), ]), - issue_metadata_rail_node(view: view, panel: panel, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), + issue_metadata_rail_node(view: view, hierarchy: hierarchy, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), ]), ]) } @@ -2389,30 +2356,71 @@ fn issue_comments_tab_nodes(view: WorkRowView, events: List Fragment { - issue_membership_link(link: link, parent: false) +// THE DEPENDENCIES TAB reads two views and never mixes them: the graph draws the dependency view +// (Blocked by above the issue, Blocking below it), and the membership section lists the membership +// view (Parent, then the issue's Sub-issues). A node in both carries the same presentation in each. +fn issue_node_status_text(status: NodeStatus) -> String { + match status { + NodeStatusDerived { status: derived, closed: _ } => issue_display_status_label(status: derived) + NodeStatusAccepted => status_chip_accepted_label + NodeStatusWithheld => status_chip_withheld_label + NodeStatusUnavailable { reason: _ } => "Status unavailable" + } } -fn issue_membership_link(link: IssueLinkView, parent: Bool) -> Fragment { - el_attrs("a", [attr(name: "class", value: "issue-graph-node"), attr(name: "href", value: concat("/issue/", link.node_id)), - attr(name: "data-complete", value: if link.status == "done" || link.status == "superseded" { "1" } else { "0" }), - attr(name: "aria-label", value: concat(if parent { "Parent project: " } else { "Child issue: " }, link.headline)), - attr(name: "title", value: concat(if parent { "Parent project · " } else { "Child issue · " }, concat(link.node_id, concat(" · ", link.status))))], [ +fn issue_node_status_reason(status: NodeStatus) -> String { + match status { + NodeStatusDerived { status: derived, closed: _ } => issue_display_status_label(status: derived) + NodeStatusAccepted => status_chip_accepted_label + NodeStatusWithheld => status_chip_withheld_label + NodeStatusUnavailable { reason } => join(["Status unavailable: ", reason], "") + } +} +fn issue_relation_row_children_attrs(children: RelationRowChildren) -> List { + match children { + RowChildrenNotCarried => [] + RowChildrenExpandable { count: n } => [attr(name: "data-sub-issues", value: to_string(value: n))] + } +} +fn issue_membership_link(row: RelationRow, relation: NodeRelation, parent: Bool) -> Fragment { + el_attrs("a", concat([attr(name: "class", value: "issue-graph-node"), attr(name: "href", value: row.node.route), + attr(name: "data-complete", value: if node_status_is_closed(status: row.node.status) { "1" } else { "0" }), + attr(name: "data-priority", value: to_string(roadmap_priority_rank(priority: row.node.priority.priority))), + attr(name: "aria-label", value: join([relation_kind_label(kind: relation.kind), ": ", row.node.title], "")), + attr(name: "title", value: join([relation_kind_label(kind: relation.kind), " · ", row.node.slug, " · ", roadmap_priority_label(priority: row.node.priority.priority), " · ", issue_node_status_reason(status: row.node.status)], ""))], issue_relation_row_children_attrs(children: row.children)), [ el_attrs("svg", [attr(name: "class", value: "issue-membership-icon"), attr(name: "viewBox", value: "0 0 16 16"), attr(name: "fill", value: "none"), attr(name: "stroke", value: "currentColor"), attr(name: "stroke-width", value: "1.5"), attr(name: "stroke-linecap", value: "round"), attr(name: "stroke-linejoin", value: "round"), attr(name: "aria-hidden", value: "true")], [el_attrs("path", [attr(name: "d", value: if parent { "M12 13H8a3 3 0 0 1-3-3V3M2 6l3-3 3 3" } else { "M3 2v6a3 3 0 0 0 3 3h7M10 8l3 3-3 3" })], [])]), - el_class("span", "issue-membership-name", [txt(link.headline)]), el_class("small", "issue-graph-state", [txt(link.status)]), + el_class("span", "issue-membership-name", [txt(row.node.title)]), el_class("small", "issue-graph-state", [txt(issue_node_status_text(status: row.node.status))]), ]) } -fn issue_graph_column(label: String, links: List) -> Fragment { - el_class("div", "issue-graph-column", [el("h3", [txt(label)]), - el_class("div", "issue-graph-branch", if count(links) == 0 { [el_class("p", "issue-panel-empty", [txt("None declared")])] } else { map(links, issue_graph_link) }), - ]) +// THE GRAPH COMPONENT'S STATE KEYS are its own interface (gunbc.site.dependency_graph +// dependency_graph_state reads "done", "superseded" and "unknown"); every other status passes as +// the one derived word. The component is scheduled for removal with the relation tables. +fn issue_dependency_graph_status(status: NodeStatus) -> String { + match status { + NodeStatusUnavailable { reason: _ } => "unknown" + NodeStatusAccepted => "done" + NodeStatusWithheld => "superseded" + NodeStatusDerived { status: derived, closed: _ } => match derived { + DisplayDone => "done" + DisplaySuperseded => "superseded" + DisplayUnknown => "unknown" + DisplayReady => issue_display_status_label(status: derived) + DisplayActive => issue_display_status_label(status: derived) + DisplayReview => issue_display_status_label(status: derived) + DisplayAssigned => issue_display_status_label(status: derived) + DisplayReturned => issue_display_status_label(status: derived) + DisplayBlocked => issue_display_status_label(status: derived) + } + } } -fn issue_dependency_graph_item(link: IssueLinkView) -> DependencyGraphItem { - DependencyGraphItem { id: link.node_id, label: link.headline, status: link.status } +fn issue_dependency_graph_item(row: RelationRow) -> DependencyGraphItem { + DependencyGraphItem { id: row.node.slug, label: row.node.title, status: issue_dependency_graph_status(status: row.node.status) } } -fn issue_dependencies_tab_nodes(view: WorkRowView, panel: IssueLinkPanel) -> List { +fn issue_dependencies_tab_nodes(view: WorkRowView, hierarchy: IssueHierarchyView) -> List { + let parent = hierarchy.membership.parent + let sub_issues = hierarchy.membership.sub_issues + let blocked_by = hierarchy.dependency.blocked_by + let blocking = hierarchy.dependency.blocking + let sub_issue_count = relation_count_text(relation: sub_issues) [el_class("section", "issue-dependency-graph", [ el("h2", [txt("Dependencies")]), el_class("p", "issue-graph-legend", [txt("Read top to bottom. Arrows lead from a prerequisite to the issue that needs it. Click a grouped node to expand its issues; select an issue to follow its dependencies.")]), @@ -2422,22 +2430,23 @@ fn issue_dependencies_tab_nodes(view: WorkRowView, panel: IssueLinkPanel) -> Lis el_attrs("span", [attr(name: "data-state", value: "done")], [txt("Done")]), el_attrs("span", [attr(name: "data-state", value: "unknown")], [txt("Unknown")]), ]), - dependency_graph_interactive(current: DependencyGraphItem { id: view.node_id, label: view.headline, status: issue_status_label(view: view) }, prerequisites: map(panel.prerequisites, issue_dependency_graph_item), dependents: map(panel.blocking, issue_dependency_graph_item)), - el_class("p", "issue-dag-empty", if count(panel.prerequisites) == 0 && count(panel.blocking) == 0 { [txt("No dependencies declared.")] } else { [] }), + dependency_graph_interactive(current: DependencyGraphItem { id: view.node_id, label: view.headline, status: issue_status_label(view: view) }, prerequisites: map(blocked_by.rows, issue_dependency_graph_item), dependents: map(blocking.rows, issue_dependency_graph_item)), + el_class("p", "issue-dag-empty", if count(blocked_by.rows) == 0 && count(blocking.rows) == 0 { [txt("No dependencies declared.")] } else { [] }), + el_class("p", "issue-graph-warning", concat(issue_relation_undeclared_nodes(relation: blocked_by), issue_relation_undeclared_nodes(relation: blocking))), el_class("div", "issue-graph-membership", [el("h3", [txt("Project membership")]), - el_class("div", "issue-membership-tree", concat(match panel.parent { Absent => [] Present { value: parent } => [issue_membership_link(link: parent, parent: true)] }, [ - el_attrs("div", [attr(name: "class", value: "issue-membership-subtree"), attr(name: "data-has-parent", value: match panel.parent { Absent => "0" Present { value: _ } => "1" })], [ + el_class("div", "issue-membership-tree", concat(map(parent.rows, row => issue_membership_link(row: row, relation: parent, parent: true)), concat(issue_relation_undeclared_nodes(relation: parent), [ + el_attrs("div", [attr(name: "class", value: "issue-membership-subtree"), attr(name: "data-has-parent", value: if count(parent.rows) == 0 { "0" } else { "1" })], [ el_class("details", "issue-membership-children", concat([ - el_attrs("summary", [attr(name: "class", value: "issue-membership-current"), attr(name: "aria-current", value: "true"), attr(name: "aria-label", value: join([view.headline, " · ", to_string(value: count(panel.children)), " child issues"], ""))], [ + el_attrs("summary", [attr(name: "class", value: "issue-membership-current"), attr(name: "aria-current", value: "true"), attr(name: "aria-label", value: join([view.headline, " · ", relation_kind_label(kind: sub_issues.kind), " ", sub_issue_count], ""))], [ el_class("span", "issue-membership-selected-mark", []), el_class("strong", "issue-membership-selected", [txt(view.headline)]), - el_attrs("span", [attr(name: "class", value: "issue-membership-count"), attr(name: "aria-hidden", value: "true")], [txt(to_string(value: count(panel.children)))]), + el_attrs("span", [attr(name: "class", value: "issue-membership-count"), attr(name: "aria-hidden", value: "true")], [txt(sub_issue_count)]), ]), - ], if count(panel.children) == 0 { [el_class("p", "issue-panel-empty", [txt("No child issues")])] } else { [el_class("div", "issue-membership-list", map(panel.children, issue_graph_link))] })), + ], concat(if count(sub_issues.rows) == 0 { [el_class("p", "issue-panel-empty", [txt(relation_empty_text(kind: sub_issues.kind))])] } else { [el_class("div", "issue-membership-list", map(sub_issues.rows, row => issue_membership_link(row: row, relation: sub_issues, parent: false)))] }, issue_relation_undeclared_nodes(relation: sub_issues)))), ]), - ])), + ]))), ]), - el_class("p", "issue-graph-warning", if panel.cycle { [txt("Dependency cycle touches this issue. Review the linked prerequisites before dispatching.")] } else { [] }), + el_class("p", "issue-graph-warning", if hierarchy.dependency.cycle { [txt("Dependency cycle touches this issue. Review the linked prerequisites before dispatching.")] } else { [] }), ])] } @@ -2450,26 +2459,32 @@ fn issue_history_tab_nodes(events: List, profiles: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> Fragment { +// THE RIGHT METADATA RAIL (owner mandate): status, assignee (the editable control), the work in +// progress, reporter, component, parent, prerequisites/blocking, delivery, return-to, modified, +// observation -- the issue's identity and state at a glance, beside the narrative, as one +// FieldList. Rows with no fact OMIT (the mandate's empty-row law), except status and assignee +// which always render; a fact known to be missing is an absent value carrying its reason. +fn issue_metadata_rail_node(view: WorkRowView, hierarchy: IssueHierarchyView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> Fragment { + el_class("aside", "issue-rail", [field_list(input: FieldList { rows: issue_rail_rows(view: view, hierarchy: hierarchy, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context) })]) +} + +fn issue_rail_rows(view: WorkRowView, hierarchy: IssueHierarchyView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> List { let assignment = roadmap_issue_assignment(envs: events) let bar = task_bar_view_of(view: view, observation: observation_impact_view(workflow_refused: none)) - el_class("aside", "issue-rail", [ - issue_field_row(label: "status", value: [issue_status_node(view: view, events: events)]), - issue_rail_assignee_row(view: view, attempts: attempts, assignment: assignment, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), - issue_field_text_row(label: "reporter", value: issue_reporter_value(events: events)), - issue_field_text_row(label: "component", value: issue_component_value(view: view)), - issue_field_row(label: "parent", value: issue_parent_value(panel: panel)), - issue_rail_optional_nodes_row(label: "blocked by", value: issue_link_count_nodes(links: panel.blocked_by)), - issue_rail_optional_nodes_row(label: "blocking", value: issue_link_count_nodes(links: panel.blocking)), - issue_rail_optional_row(label: "delivery", value: issue_delivery_value(attempts: attempts)), - issue_rail_return_to_row(view: view, assignment: assignment), - issue_rail_optional_row(label: "modified", value: issue_modified_value(events: events)), - issue_rail_optional_row(label: "observation", value: if count(attempts) == 0 { "" } else { bar.signal }), - ]) + flat_map([ + [FieldRow { label: "Status", value: FieldStatusControl { status: issue_display_status(view: view), detail: issue_status_detail_nodes(view: view, events: events) } }], + [issue_rail_assignee_row(view: view, attempts: attempts, assignment: assignment, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context)], + issue_rail_build_meter_rows(view: view, attempts: attempts, assignment: assignment), + [FieldRow { label: "Reporter", value: issue_reporter_value(events: events) }], + [FieldRow { label: "Component", value: issue_rail_component_value(view: view) }], + [FieldRow { label: "Parent", value: issue_parent_value(parent: hierarchy.membership.parent) }], + issue_prerequisites_rail_rows(view: view, blocked_by: hierarchy.dependency.blocked_by), + issue_rail_count_rows(label: "Blocking", n: relation_summary(relation: hierarchy.dependency.blocking).total), + issue_rail_text_rows(label: "Delivery", value: issue_delivery_value(attempts: attempts)), + issue_rail_return_to_rows(view: view, assignment: assignment), + issue_rail_text_rows(label: "Modified", value: issue_modified_value(events: events)), + issue_rail_text_rows(label: "Observation", value: if count(attempts) == 0 { "" } else { bar.signal }), + ], rows => rows) } // Status detail keeps the latest ledger standing alongside the observed planning facts. @@ -2480,59 +2495,76 @@ fn issue_standing_text(s: RoadmapNodeStanding, profiles: List) roadmap_node_standing_text_naming(s: s, name: fn(p) { event_principal_display_with_profile(p: p, profiles: profiles).name }) } +// A principal as the field list shows a person: the avatar circle, the address (the display's +// detail) on one line, and the full identity for the tooltip. +fn issue_rail_person(holder: EventPrincipal, profiles: List) -> FieldPerson { + let display = event_principal_display_with_profile(p: holder, profiles: profiles) + let workflow = event_principal_is_fabric(p: holder) + FieldPerson { face: issue_avatar_chip_node(display: display, workflow: workflow), identity: display.detail, full: Present { value: issue_avatar_title(display: display, workflow: workflow) } } +} + // The current identity becomes the editor in place; closing restores the projected holder. -fn issue_rail_assignee_row(view: WorkRowView, attempts: List, assignment: IssueAssignmentProjection, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> Fragment { - let face = match assignment.holder { - Absent => unassigned_avatar() - Present { value: holder } => issue_avatar_chip_node(display: event_principal_display_with_profile(p: holder, profiles: view.profiles), workflow: event_principal_is_fabric(p: holder)) +fn issue_rail_assignee_row(view: WorkRowView, attempts: List, assignment: IssueAssignmentProjection, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }) -> FieldRow { + let person = match assignment.holder { + Absent => FieldPerson { face: unassigned_avatar(), identity: "Unassigned", full: none } + Present { value: holder } => issue_rail_person(holder: holder, profiles: view.profiles) + } + let current = match assignment.holder { Absent => "" Present { value: _ } => person.identity } + FieldRow { label: "Assignee", value: FieldIdentityControl { + person: person, + action: "Change assignee", + editor: issue_detail_action_bar_nodes(view: view, current_assignee: current, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), + } } +} + +// THE COMPACT WORK SUMMARY, DIRECTLY UNDER THE ASSIGNEE. Whether the issue has one is the +// producer's decision (gunbc.roadmap.roadmap_build_meter compact_work_summary: Fabric holds it and +// it has an attempt); the rail only places it, as the field list's Work row, a link into the Work +// tab carrying the tab chip so the page's tab control opens that tab in place. No summary, no +// row. +fn issue_rail_build_meter_rows(view: WorkRowView, attempts: List, assignment: IssueAssignmentProjection) -> List { + match compact_work_summary(holder: assignment.holder, attempts: attempts, stale: view.attempt_stale) { + Absent => [] + Present { value: summary } => [ + FieldRow { label: "Work", value: FieldWork { summary: summary, link_attributes: [attr(name: "data-tab-chip", value: issue_work_tab_anchor)] } }, + ] } - let label = match assignment.holder { Absent => "Unassigned" Present { value: holder } => event_principal_display_with_profile(p: holder, profiles: view.profiles).detail } - issue_field_row(label: "assignee", value: [el_class("details", "issue-assignee-disclosure", concat([ - el_attrs("summary", [attr(name: "aria-label", value: concat("Change assignee: ", label))], [face, el_class("span", "issue-assignee-identity", [txt(label)])]), - ], issue_detail_action_bar_nodes(view: view, current_assignee: if label == "Unassigned" { "" } else { label }, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context)))]) } // THE RETURN-TO ROW: the stored merge owner the work comes back to (the publication handoff's -// target) — rendered when any assignment recorded one. -fn issue_rail_return_to_row(view: WorkRowView, assignment: IssueAssignmentProjection) -> Fragment { +// target) — a row only when an assignment recorded one. +fn issue_rail_return_to_rows(view: WorkRowView, assignment: IssueAssignmentProjection) -> List { match assignment.return_to { - Present { value: return_to } => - issue_field_row(label: "return to", value: issue_assignee_chip_node(holder: return_to, profiles: view.profiles)) - Absent => - el_class("div", "issue-field", []) - } -} - -fn issue_rail_optional_row(label: String, value: String) -> Fragment { - if value == "" { - el_class("div", "issue-field", []) - } else { - issue_field_text_row(label: label, value: value) + Present { value: return_to } => [FieldRow { label: "Return To", value: FieldIdentity { person: issue_rail_person(holder: return_to, profiles: view.profiles) } }] + Absent => [] } } -fn issue_rail_optional_nodes_row(label: String, value: List) -> Fragment { - if count(value) == 0 { - el_class("div", "issue-field", []) +// THE RAIL CARRIES A SIZE, NOT A POPULATION. Every blocker's headline comma-joined into the rail +// wrapped ten titles down the page. The rail names how many, and the count opens the +// Dependencies tab (the same data-tab-chip the tab strip uses), where each target is its own +// link. Nothing to count, no row. +fn issue_rail_count_rows(label: String, n: Int) -> List { + if n == 0 { + [] } else { - issue_field_row(label: label, value: value) + [FieldRow { label: label, value: FieldAction { + text: join([to_string(value: n), if n == 1 { " issue" } else { " issues" }], ""), + attributes: [attr(name: "data-tab-chip", value: "dependencies")], + } }] } } -// THE RAIL CARRIES A RELATION'S SIZE, NOT ITS POPULATION. The rail is a 34ch column: every blocker's -// headline comma-joined into it wrapped ten titles down the page. The rail names how many, and the -// count opens the Dependencies tab (the same data-tab-chip the tab strip uses), where each target -// is its own link. No relation, no row. -fn issue_link_count_nodes(links: List) -> List { - let n = count(links) - if n == 0 { - [] - } else { - [ el_attrs("button", [ - attr(name: "class", value: "issue-rail-link" ), - attr(name: "type", value: "button" ), - attr(name: "data-tab-chip", value: "dependencies" ), - ], [ txt(join([to_string(value: n), if n == 1 { " issue" } else { " issues" }], "")) ]) ] +// WHAT IS PREVENTING THIS ISSUE FROM PROCEEDING, as far as its prerequisites go. The count is the +// row's own unmet prerequisites (gunbc.roadmap_presentation row_unmet_prerequisites, the +// readiness predicate's blockers), never the size of the Blocked by relation: that relation lists +// every declared prerequisite, satisfied or not, on the Dependencies tab. Where the row's +// position was decided without assessing readiness the rail says only how many prerequisites are +// declared, under a label that claims nothing about them. +fn issue_prerequisites_rail_rows(view: WorkRowView, blocked_by: NodeRelation) -> List { + match row_unmet_prerequisites(view: view) { + UnmetPrerequisitesAssessed { ids } => issue_rail_count_rows(label: "Unmet Prerequisites", n: count(ids)) + UnmetPrerequisitesNotAssessed => issue_rail_count_rows(label: "Prerequisites", n: relation_summary(relation: blocked_by).total) } } @@ -2540,8 +2572,8 @@ fn issue_link_count_nodes(links: List) -> List { // issue on this surface is an authored roadmap row, opened by nobody the ledger can name. The row // says so rather than promoting the owner lane (a component, e.g. @shell) to a person. When an // IssueCreated kind lands, its author is this value. -fn issue_reporter_value(events: List) -> String { - "not recorded (authored roadmap row)" +fn issue_reporter_value(events: List) -> FieldValue { + FieldAbsent { reason: "Not recorded: an authored roadmap row has no creation event" } } // DELIVERY: the latest attempt's short key names the delivery vehicle until issue→PR links land @@ -2561,15 +2593,15 @@ fn issue_modified_value(events: List) -> String { } } -fn issue_detail_page_launch(view: WorkRowView, panel: IssueLinkPanel, observation: ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }, profiles: List) -> Fragment { +fn issue_detail_page_launch(view: WorkRowView, hierarchy: IssueHierarchyView, observation: ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }, profiles: List) -> Fragment { site_document(title: join(["gunbc — issue ", view.node_id], ""), heading: "issue", styles: roadmap_styles(), content: [ -issue_detail_page(view: view, panel: panel, observation: observation, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), +issue_detail_page(view: view, hierarchy: hierarchy, observation: observation, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), dispatch_client_script() ]) } // THE DETAIL ROUTE'S RENDER ENTRY (owner ruling 2026-09-24: the detail page is a real served -// route): one issue's decided row view, link panel, and Work-tab history rendered from the SAME +// route): one issue's decided row view, hierarchy view, and Work-tab history rendered from the SAME // authorities the daily page reads — the plan for the ticket and links, the standings observation // for the holder, the node's event log for the durable execution history. Attempts ride the same // list the static render takes; the live attempt feed is the belt-side observation-storage @@ -2619,22 +2651,14 @@ fn issue_detail_page_for( let edges = doc_all_edges(doc: doc) let ctx = page_launch_context(doc: doc, merged: authored_merged_prs(), launch: launch) let observation = observation_impact_view(workflow_refused: attempts_refused) + let obs = RowObservations { graph: row_graph(nodes: nodes, edges: edges), merged: authored_merged_prs(), attempts: attempts, attempts_refused: attempts_refused, standings: standings, launch: ctx, stale_nodes: stale_nodes, profiles: profiles } match (filter(nodes, n => (n.node as String) == node_id)).first() { Absent => issue_detail_not_found_page(node_id: node_id, viewer: viewer) Present { value: rn } => issue_detail_page_launch( - view: work_row_view( - rn: rn, nodes: nodes, edges: edges, merged: authored_merged_prs(), - activity: daily_row_activity(node_id: node_id, attempts: attempts, attempts_refused: attempts_refused), - rounds: daily_row_rounds(node_id: node_id, attempts: attempts, attempts_refused: attempts_refused), - worker_alive: daily_row_worker_alive(node_id: node_id, attempts: attempts, attempts_refused: attempts_refused), - standing: standing_for_node(standings: standings, node_id: node_id), - assignment: assignment_for_node(standings: standings, node_id: node_id), - launch: ctx, - attempt_stale: any(stale_nodes, s => s == node_id), - profiles: profiles), - panel: gunbc.roadmap_presentation.issue_link_panel(rn: rn, nodes: nodes, edges: edges, member_titles: declared_roadmap_nodes(), merged: authored_merged_prs()), + view: observed_row_view(rn: rn, obs: obs), + hierarchy: issue_detail_hierarchy_for(rn: rn, plan: nodes, declared: declared_roadmap_nodes(), edges: edges, obs: obs, accepted: accepted, withheld: withheld), observation: observation, attempts: attempts, events: events, @@ -2753,6 +2777,75 @@ fn live_work_family_members_of(nodes: List, index: Map + attempts: List + attempts_refused: String? + standings: RoadmapStandings + launch: LaunchRowContext + stale_nodes: List + profiles: List +} + +fn observed_row_view(rn: RoadmapNode, obs: RowObservations) -> WorkRowView { + work_row_view_in( + rn: rn, graph: obs.graph, merged: obs.merged, + activity: daily_row_activity(node_id: rn.node as String, attempts: obs.attempts, attempts_refused: obs.attempts_refused), + rounds: daily_row_rounds(node_id: rn.node as String, attempts: obs.attempts, attempts_refused: obs.attempts_refused), + worker_alive: daily_row_worker_alive(node_id: rn.node as String, attempts: obs.attempts, attempts_refused: obs.attempts_refused), + standing: standing_for_node(standings: obs.standings, node_id: rn.node as String), + assignment: assignment_for_node(standings: obs.standings, node_id: rn.node as String), + launch: obs.launch, + attempt_stale: any(obs.stale_nodes, s => s == rn.node as String), + profiles: obs.profiles) +} + +// A RELATED NODE'S STATUS is its own row's status when the plan carries a row for it. The row is +// looked up among the PLAN rows (the graph's own index), because readiness -- and so the status -- +// is defined over that graph. A declared node the plan omits has no row, but its lifecycle is still +// established (gunbc.roadmap_presentation omitted_node_lifecycle): an accepted or superseded one is +// shown with the same status a closed plan row gets and counts as closed. Only an omitted node +// whose lifecycle is not closed is unavailable -- nothing decides what an open node outside the +// plan should be shown as -- and the reason says why the plan omits it. +fn observed_node_status(rn: RoadmapNode, obs: RowObservations, accepted: List, withheld: List) -> NodeStatus { + match lineage_lookup(index: obs.graph.lineage, id: rn.node as String) { + Present { value: planned } => { + let view = observed_row_view(rn: planned, obs: obs) + NodeStatusDerived { status: issue_display_status(view: view), closed: work_lifecycle_is_closed(l: view.lifecycle) } + } + Absent => { + let own = omitted_node_display_status(rn: rn, merged: obs.merged, accepted: false) + let superseded = match own { Present { value: s } => s == DisplaySuperseded Absent => false } + if superseded { + NodeStatusDerived { status: DisplaySuperseded, closed: true } + } else if any(accepted, id => id == rn.node) { + NodeStatusAccepted + } else { + match own { + Present { value: status } => NodeStatusDerived { status: status, closed: true } + Absent => if any(withheld, id => id == rn.node) { NodeStatusWithheld } else { NodeStatusUnavailable { reason: "no authority establishes a status: the plan carries no row for this node, and neither the acceptance authority nor the plan's withheld set names it" } } + } + } + } + } +} + +// THE ISSUE PAGE'S HIERARCHY, as the served entry derives it: the universe is the plan rows joined +// with the declared rows the plan omits, the edges are the plan's, and every related node's status +// is observed_node_status over the same observations the page's own row was decided from. +fn issue_detail_hierarchy_for(rn: RoadmapNode, plan: List, declared: List, edges: List, obs: RowObservations, accepted: List, withheld: List) -> IssueHierarchyView { + issue_hierarchy_view( + node: rn.node, + nodes: hierarchy_nodes(plan: plan, declared: declared), + edges: edges, + status_of: fn(related) { observed_node_status(rn: related, obs: obs, accepted: accepted, withheld: withheld) }) +} + fn page_launch_context(doc: RoadmapDocument, merged: List, launch: LaunchHostStanding) -> LaunchRowContext { LaunchRowContext { standing: launch, graph: launch_graph_of_document(doc: doc), merged: merged } } @@ -2774,16 +2867,8 @@ fn daily_workspace_view_of_for_accepted( let observation = observation_impact_view(workflow_refused: attempts_refused) let ctx = page_launch_context(doc: doc, merged: merged, launch: launch) let graph = row_graph(nodes: nodes, edges: edges) - let rows = map(nodes, rn => work_row_view_in( - rn: rn, graph: graph, merged: merged, - activity: daily_row_activity(node_id: rn.node as String, attempts: attempts, attempts_refused: attempts_refused), - rounds: daily_row_rounds(node_id: rn.node as String, attempts: attempts, attempts_refused: attempts_refused), - worker_alive: daily_row_worker_alive(node_id: rn.node as String, attempts: attempts, attempts_refused: attempts_refused), - standing: standing_for_node(standings: standings, node_id: rn.node as String), - assignment: assignment_for_node(standings: standings, node_id: rn.node as String), - launch: ctx, - attempt_stale: any(stale_nodes, s => s == rn.node as String), - profiles: profiles)) + let obs = RowObservations { graph: graph, merged: merged, attempts: attempts, attempts_refused: attempts_refused, standings: standings, launch: ctx, stale_nodes: stale_nodes, profiles: profiles } + let rows = map(nodes, rn => observed_row_view(rn: rn, obs: obs)) let ready_count = count(rows |> filter(r => !row_is_active_work(row: r) && work_scheduling_key(s: r.scheduling) == "ready")) let shown_index = membership_index(memberships: memberships) @@ -3128,18 +3213,17 @@ fn roadmap_daily_workspace_page_for_projection(proj: RoadmapAuthorityProjection, } -fn issue_rail_status_text(view: WorkRowView, events: List) -> String { issue_status_label(view: view) } -fn issue_status_node(view: WorkRowView, events: List = []) -> Fragment { +// What the status control opens onto: the four facts the status was decided from. +fn issue_status_detail_nodes(view: WorkRowView, events: List = []) -> List { let facts = issue_status_details(view: view) - el_class("details", "issue-status-detail", [ - el("summary", [status_badge(label: issue_status_label(view: view))]), + [ el_class("dl", "issue-status-facts", [ el("dt", [txt("Planning")]), el("dd", concat([txt(facts.planning)], if count(events) == 0 { [] } else { [el("p", [txt(issue_standing_text(s: roadmap_node_standing(envs: events), profiles: view.profiles))])] })), el("dt", [txt("Current obligation")]), el("dd", [txt(if facts.obligation == "" { "No unsettled obligation" } else { facts.obligation })]), el("dt", [txt("Attempt")]), el("dd", [txt(facts.outcome)]), el("dt", [txt("Delivery evidence")]), el("dd", if count(facts.evidence) == 0 { [txt("No delivery evidence observed")] } else { map(facts.evidence, e => el("p", [txt(join([e.label, ": ", e.value], ""))])) }), ]), - ]) + ] } // Profile disclosure is local UI. No click on this card issues a command. diff --git a/dag/gunbc/roadmap/roadmap_presentation.dag b/dag/gunbc/roadmap/roadmap_presentation.dag index bd71b5cea51..1c254c16cf4 100644 --- a/dag/gunbc/roadmap/roadmap_presentation.dag +++ b/dag/gunbc/roadmap/roadmap_presentation.dag @@ -1,12 +1,17 @@ module gunbc.roadmap_presentation import gunbc.roadmap_nesting { RoadmapMembership, roadmap_memberships } +import gunbc.roadmap_issue_status { + IssueDisplayStatus, DisplayReady, DisplayAssigned, DisplayActive, DisplayReview, DisplayBlocked, + DisplayReturned, DisplayDone, DisplaySuperseded, DisplayUnknown, + issue_display_status_label, issue_display_status_sort_rank, +} import v2.std.collection { Map } import gunbc.roadmap.roadmap_readiness { ReadinessGap, NodeReadiness, ReadyToDispatch, NotReady, node_readiness, readiness_text, readiness_reason_text, - readiness_is_dispatchable, + readiness_is_dispatchable, readiness_gap_blockers, } -import gunbc.roadmap.roadmap_project { node_project_membership, node_project_membership_in, MembershipMember, project_member_ids, RoadmapMembershipIndex, membership_index, project_member_ids_in } +import gunbc.roadmap.roadmap_project { node_project_membership_in, MembershipMember, RoadmapMembershipIndex, membership_index, project_member_ids_in } import gunbc.roadmap.roadmap_event_log { RoadmapNodeStanding, IssueAssignmentProjection, @@ -14,7 +19,7 @@ import gunbc.roadmap.roadmap_event_log { } import gunbc.roadmap_model { RoadmapNode, RoadmapEdge, Pointer, - roadmap_parent_ids, roadmap_primary_parent, + roadmap_primary_parent, RoadmapParent, TopLevel, UnderParent, WorkItemDispatch, NotDispatchable, Dispatchable, node_sizing_derivation, @@ -30,7 +35,7 @@ import gunbc.roadmap_status { } import gunbc.roadmap_spawner { node_is_spawnable, node_is_upcoming, node_is_frontier_done, node_closes_dependency, - ReadinessGraph, readiness_graph, node_readiness_in_graph, node_unmet_parent_ids, + ReadinessGraph, readiness_graph, node_readiness_in_graph, } import gunbc.roadmap_launch_admission { LaunchCause, Operator, LaunchIdentity, launch_cause_label, @@ -311,10 +316,6 @@ type WorkScheduling | SchedulingDone | SchedulingSuperseded -fn scheduling_blockers(rn: RoadmapNode, nodes: List, edges: List) -> List { - node_unmet_parent_ids(nodes: nodes, edges: edges, id: rn.node) |> map(p => p as String) -} - // A page projects every row's readiness over one graph, so it indexes the graph once // (gunbc.roadmap_spawner readiness_graph) and each row's answer is lookups. The page's readiness // walks lineage over the rendered graph itself: the projected graph carries every open project, so @@ -345,6 +346,24 @@ fn work_scheduling_in(rn: RoadmapNode, graph: ReadinessGraph, merged: List) } } +// WHICH PREREQUISITES ARE HOLDING THE ROW, read from the scheduling position and nowhere else. A +// ready row has none; a not-ready row has the blockers its readiness named +// (gunbc.roadmap.roadmap_readiness GapDependencies), which is the dispatcher's own satisfaction +// predicate. A done or superseded row was never asked -- its position is decided before readiness +// is computed (work_scheduling_in) -- so the answer there is that it was not assessed, never zero. +type UnmetPrerequisites + = UnmetPrerequisitesAssessed { ids: List } + | UnmetPrerequisitesNotAssessed + +fn row_unmet_prerequisites(view: WorkRowView) -> UnmetPrerequisites { + match view.scheduling { + SchedulingReady => UnmetPrerequisitesAssessed { ids: [] } + SchedulingNotReady { missing } => UnmetPrerequisitesAssessed { ids: flat_map(missing, g => readiness_gap_blockers(g: g)) } + SchedulingDone => UnmetPrerequisitesNotAssessed + SchedulingSuperseded => UnmetPrerequisitesNotAssessed + } +} + fn work_scheduling(rn: RoadmapNode, nodes: List, edges: List, merged: List = []) -> WorkScheduling { work_scheduling_in(rn: rn, graph: row_graph(nodes: nodes, edges: edges), merged: merged) } @@ -361,7 +380,7 @@ fn work_scheduling_key(s: WorkScheduling) -> String { // The dependency line's claim moved to the taskbar (2026-09-21 redesign): the bar's signal slot // carries "blocked on X, Y" for every upcoming row — the blocker is visible IN the bar, never // gated on a multi-blocker count, because the tree no longer tells it positionally once rows are -// the page's structure. The seam's scheduling_blockers derivation is unchanged; only the old +// the page's structure. The blockers are the readiness value's own GapDependencies; the old // title-adjacent line's presentation is deleted. type SupportingFact { label: String @@ -1784,8 +1803,10 @@ type ProjectChainWalk { chain: List } -fn row_is_closed(row: WorkRowView) -> Bool { - match row.lifecycle { +// CLOSED IS A LIFECYCLE FACT: a done or a superseded node is closed, and nothing else is. Every +// reader that asks whether a node is closed asks here, whatever status the node is shown with. +fn work_lifecycle_is_closed(l: WorkLifecycle) -> Bool { + match l { OpenLifecycle => false ReviewLifecycle => false DoneLifecycle => true @@ -1793,6 +1814,10 @@ fn row_is_closed(row: WorkRowView) -> Bool { } } +fn row_is_closed(row: WorkRowView) -> Bool { + work_lifecycle_is_closed(l: row.lifecycle) +} + fn row_key_is_banded(key: String) -> Bool { key == "ready" || key == "not-ready" || key == "done" || key == "superseded" } @@ -2046,81 +2071,6 @@ fn workspace_flat_table( flat_map(superseded, e => band_entry_flat_rows(e: e)))))) } -// THE ISSUE DETAIL'S LINK PANEL (owner ruling 2026-09-22: blocking edges express dependencies). -// Blocking lists the node's children — the dependents this issue structurally blocks; Blocked-by -// lists only the UNMET parents — an edge whose parent has closed is satisfied, not an obstruction, -// and naming it as one would contradict the bar's "blocked on" signal, which derives from this -// same scheduling_blockers seam. A link carries node id + headline only: the panel is a pointer -// to the one place each issue renders, never a second row view. -type IssueLinkView { - node_id: String - headline: String - status: String -} - -// Nesting and blocking are independent dimensions with one shared ownership authority. -type IssueLinkPanel { - parent: IssueLinkView? - children: List - blocking: List - blocked_by: List - prerequisites: List - cycle: Bool -} - -fn issue_link_view_of(rn: RoadmapNode, merged: List = []) -> IssueLinkView { - IssueLinkView { node_id: rn.node as String, headline: row_headline(line: rn.line), status: work_lifecycle_key(l: work_lifecycle(rn: rn, merged: merged)) } -} - -// A child is a node that names this one among its parents — the same roadmap_parent_ids read the -// scheduling seam consumes, so the panel cannot list a dependency the frontier would not. -fn child_nodes_of(nodes: List, edges: List, parent: RoadmapNodeId) -> List { - filter(xs: nodes, predicate: n => - any(roadmap_parent_ids(edges: edges, node: n.node), p => p == parent)) -} - -// MEMBERSHIP IS THE FACT; A TITLE IS ONLY ITS SPELLING. The plan rows the page renders omit -// authored program rows such as the project root, so a parent looked up only among them read -// Absent and rendered "top-level" for an issue the breadcrumb (over every declared node) shows -// under the root -- a membership erased by a missing title. The declared membership decides the -// relation; the title is looked up among the plan rows, then among `member_titles` (the page passes -// every declared node), and a target with no title anywhere shows its id -- never absence. -fn issue_member_link_view(id: String, nodes: List, member_titles: List, merged: List = []) -> IssueLinkView { - match find_node(nodes: nodes, node_id: id) { - Present { value: p } => issue_link_view_of(rn: p, merged: merged) - Absent => match find_node(nodes: member_titles, node_id: id) { - Present { value: p } => issue_link_view_of(rn: p, merged: merged) - Absent => IssueLinkView { node_id: id, headline: id, status: "unknown" } - } - } -} - -fn issue_link_panel(rn: RoadmapNode, nodes: List, edges: List, memberships: List = roadmap_memberships(), member_titles: List = [], merged: List = []) -> IssueLinkPanel { - IssueLinkPanel { - parent: match node_project_membership(node_id: rn.node as String, memberships: memberships) { - MembershipMember { arc } => Present { value: issue_member_link_view(id: arc, nodes: nodes, member_titles: member_titles, merged: merged) } - MembershipStandalone => none - MembershipAmbiguous { arcs: _ } => none - }, - children: map(project_member_ids(arc: rn.node as String, memberships: memberships), id => issue_member_link_view(id: id, nodes: nodes, member_titles: member_titles, merged: merged)), - blocking: map(child_nodes_of(nodes: nodes, edges: edges, parent: rn.node), n => issue_link_view_of(rn: n, merged: merged)), - prerequisites: map(roadmap_parent_ids(edges: edges, node: rn.node), id => issue_member_link_view(id: id as String, nodes: nodes, member_titles: member_titles, merged: merged)), - cycle: dependency_cycle_touches(node_id: rn.node as String, edges: edges), - blocked_by: flat_map(scheduling_blockers(rn: rn, nodes: nodes, edges: edges), pid => - match find_node(nodes: nodes, node_id: pid) { - Present { value: p } => [ issue_link_view_of(rn: p, merged: merged) ] - Absent => [] - }), - } -} - -fn find_node(nodes: List, node_id: String) -> RoadmapNode? { - fold(nodes, init: none, f: (acc, n) => match acc { - Present { value: v } => Present { value: v } - Absent => if (n.node as String) == node_id { Present { value: n } } else { none } - }) -} - // THE WORK TAB'S EXECUTION HISTORY, newest first: the attempt's clocked rounds reversed so the // freshest turn heads the list. The rounds are the harness ledger's own segments — nothing is // recomputed; the reversal is the only decision, and it is a presentation fact belonging to this @@ -2134,7 +2084,48 @@ fn issue_work_history_rounds(ledger: HarnessRoundLedger) -> List { // ruling here. The primary word is a projection, never stored. The four facts remain // separate in IssueStatusDetails and the Work ledger; pending stages retain wait targets. type IssueStatusDetails { planning: String, obligation: String, outcome: String, evidence: List } -fn issue_status_label(view: WorkRowView) -> String { +// THE STATUS OF A CLOSED LIFECYCLE, decided once: superseded shows Superseded and done shows Done. +// issue_display_status reads it for a plan row, and omitted_node_display_status reads it for a +// declared node the plan omits, so the two cannot map a closed node differently. It is asked only +// of a closed lifecycle (work_lifecycle_is_closed); an open or in-review lifecycle has no status +// of its own without the row's scheduling and activity, and answers Unknown here rather than a +// guess. +fn closed_lifecycle_display_status(l: WorkLifecycle) -> IssueDisplayStatus { + match l { + SupersededLifecycle => DisplaySuperseded + DoneLifecycle => DisplayDone + OpenLifecycle => DisplayUnknown + ReviewLifecycle => DisplayUnknown + } +} + +// A DECLARED NODE THE PLAN OMITS has no row, so no readiness, standing or activity is defined for +// it. Its lifecycle still is: its own line and signoff decide it exactly as they do for a plan row +// (work_lifecycle), and a node the acceptance authority lists as accepted +// (gunbc.roadmap_authority frontier_sets_projection `accepted`, the reason the plan omits it) is +// done. A superseded line stays superseded: acceptance does not reopen that question. +fn omitted_node_lifecycle(rn: RoadmapNode, merged: List, accepted: Bool) -> WorkLifecycle { + match work_lifecycle(rn: rn, merged: merged) { + SupersededLifecycle => SupersededLifecycle + DoneLifecycle => DoneLifecycle + OpenLifecycle => if accepted { DoneLifecycle } else { OpenLifecycle } + ReviewLifecycle => if accepted { DoneLifecycle } else { ReviewLifecycle } + } +} + +// What an omitted node is shown with: the closed-lifecycle status when its lifecycle is closed, +// and nothing otherwise -- an omitted node that is not closed has no producer for a status. +fn omitted_node_display_status(rn: RoadmapNode, merged: List, accepted: Bool) -> IssueDisplayStatus? { + let lifecycle = omitted_node_lifecycle(rn: rn, merged: merged, accepted: accepted) + let shown: IssueDisplayStatus? = Present { value: closed_lifecycle_display_status(l: lifecycle) } + if work_lifecycle_is_closed(l: lifecycle) { shown } else { none } +} + +// The displayed status is a typed value (gunbc.roadmap_issue_status IssueDisplayStatus); the word +// and the sort rank are read from it. The last arm reads the scheduling position directly, so +// every row lands on a member of the vocabulary: the former catch-all word "Open" was reachable +// only by a scheduling position this match now names. +fn issue_display_status(view: WorkRowView) -> IssueDisplayStatus { let standing = match view.standing { Absent => "unknown" Present { value: st } => match st { @@ -2151,32 +2142,27 @@ fn issue_status_label(view: WorkRowView) -> String { let lifecycle = work_lifecycle_key(l: view.lifecycle) let scheduling = work_scheduling_key(s: view.scheduling) let activity = activity_arm_key(a: view.activity) - if standing == "unknown" || activity == "unobservable" { "Unknown" } - else if lifecycle == "superseded" { "Superseded" } - else if lifecycle == "done" { "Done" } - else if standing == "blocked" || activity == "refused" || activity == "legacy_supersedable" { "Blocked" } - else if standing == "in_progress" || activity == "active" { "Active" } - else if lifecycle == "review" || standing == "ready_for_review" || scheduling == "done" { "Review" } - else if standing == "handed_back" { "Returned" } - else if standing == "claimed" { "Assigned" } - else if scheduling == "not-ready" { "Blocked" } - else if scheduling == "ready" { "Ready" } - else { "Open" } -} -// Owner 2026-10-04: descending status means Ready first, not lexical order. -// The same numeric value drives the initial server order and interactive column sorting. + if standing == "unknown" || activity == "unobservable" { DisplayUnknown } + else if work_lifecycle_is_closed(l: view.lifecycle) { closed_lifecycle_display_status(l: view.lifecycle) } + else if standing == "blocked" || activity == "refused" || activity == "legacy_supersedable" { DisplayBlocked } + else if standing == "in_progress" || activity == "active" { DisplayActive } + else if lifecycle == "review" || standing == "ready_for_review" || scheduling == "done" { DisplayReview } + else if standing == "handed_back" { DisplayReturned } + else if standing == "claimed" { DisplayAssigned } + else { + match view.scheduling { + SchedulingReady => DisplayReady + SchedulingNotReady { missing: _ } => DisplayBlocked + SchedulingDone => DisplayReview + SchedulingSuperseded => DisplaySuperseded + } + } +} +fn issue_status_label(view: WorkRowView) -> String { + issue_display_status_label(status: issue_display_status(view: view)) +} fn issue_status_sort_rank(view: WorkRowView) -> Int { - let label = issue_status_label(view: view) - if label == "Ready" { 10 } - else if label == "Active" { 9 } - else if label == "Review" { 8 } - else if label == "Assigned" { 7 } - else if label == "Returned" { 6 } - else if label == "Open" { 5 } - else if label == "Blocked" { 4 } - else if label == "Unknown" { 3 } - else if label == "Done" { 2 } - else { 1 } + issue_display_status_sort_rank(status: issue_display_status(view: view)) } fn issue_status_details(view: WorkRowView) -> IssueStatusDetails { diff --git a/dag/gunbc/roadmap/roadmap_readiness.dag b/dag/gunbc/roadmap/roadmap_readiness.dag index 33fbf5f5e67..61fb4241c38 100644 --- a/dag/gunbc/roadmap/roadmap_readiness.dag +++ b/dag/gunbc/roadmap/roadmap_readiness.dag @@ -194,6 +194,24 @@ fn readiness_gap_reason(g: ReadinessGap) -> String { } } +// THE PREREQUISITES A GAP NAMES AS UNMET: the blockers of a dependency gap, and none for any other +// gap. A reader that wants to know which prerequisites are holding a node asks this of the node's +// readiness, and never re-derives it from the prerequisites' statuses. +fn readiness_gap_blockers(g: ReadinessGap) -> List { + match g { + GapDependencies { blockers } => blockers + GapSuperseded { by: _ } => [] + GapAccepted => [] + GapInReview => [] + GapParent => [] + GapRoot { detail: _ } => [] + GapCentering => [] + GapDispatch => [] + GapContract { detail: _ } => [] + GapNotAgentWork { mode: _ } => [] + } +} + fn readiness_missing_labels(r: NodeReadiness) -> List { match r { ReadyToDispatch => [] diff --git a/dag/gunbc/roadmap/roadmap_serve.dag b/dag/gunbc/roadmap/roadmap_serve.dag index 17fed6fb373..9413b7bd770 100644 --- a/dag/gunbc/roadmap/roadmap_serve.dag +++ b/dag/gunbc/roadmap/roadmap_serve.dag @@ -9,6 +9,7 @@ import gunbc.site.web_fonts { web_font_stylesheet, web_font_stylesheet_path, web import extdeps.languages.json.emit { JsonNull } import gunbc.site.document { site_document } +import gunbc.site.specimen { specimen_route_path, specimen_page } import gunbc.site.markup { el, txt } import gunbc.site.brand { site_favicon_path, site_favicon_type, site_favicon_source } import gunbc.site.controls { site_client_path, site_client_source } @@ -285,6 +286,7 @@ type RoadmapServeHandler | AllocationReleaseHandler | AllocationClientHandler | FleetStandingPageHandler + | SpecimenPageHandler | DispatchJsonHandler | HealthzHandler | DashboardInstanceHandler @@ -364,6 +366,7 @@ fn roadmap_serve_own_route_specs() -> List> ServedRouteSpec { method: GET, raw: web_font_stylesheet_path(), handler: WebFontStylesheetHandler }, ServedRouteSpec { method: GET, raw: allocation_client_path, handler: AllocationClientHandler }, ServedRouteSpec { method: GET, raw: "/fleet", handler: FleetStandingPageHandler }, + ServedRouteSpec { method: GET, raw: specimen_route_path, handler: SpecimenPageHandler }, ServedRouteSpec { method: GET, raw: roadmap_site_dispatch_url_path as String, handler: DispatchJsonHandler }, ServedRouteSpec { method: GET, raw: roadmap_site_health_path as String, handler: HealthzHandler }, ServedRouteSpec { method: GET, raw: "/instance.json", handler: DashboardInstanceHandler }, @@ -1260,6 +1263,7 @@ fn roadmap_serve_invoke_over( AllocationGetHandler => workspace_route_get(request: request, allocation_id: path_param_value(params: params, name: "allocation_id") as NonEmptyStr) AllocationClientHandler => serve_text_response(status: 200, content_type: text_javascript_utf8, body: allocation_client_source()) FleetStandingPageHandler => serve_page_response(page: roadmap_fleet_page()) + SpecimenPageHandler => serve_page_response(page: specimen_page()) DispatchJsonHandler => serve_text_response(status: 200, content_type: application_json_utf8, body: roadmap_site_dispatch_json_body_over(plan: plan())) HealthzHandler => serve_text_response(status: 200, content_type: application_json_utf8, body: roadmap_site_healthz_body_for_release(release_revision: release_revision)) DashboardInstanceHandler => diff --git a/dag/gunbc/roadmap/roadmap_served_observation.dag b/dag/gunbc/roadmap/roadmap_served_observation.dag index 2f44027a12c..3c89b21626f 100644 --- a/dag/gunbc/roadmap/roadmap_served_observation.dag +++ b/dag/gunbc/roadmap/roadmap_served_observation.dag @@ -246,8 +246,7 @@ fn served_attempt_revision_observations(instance: HostDashboardInstance, attempt // (the tick has the instance in hand; serve reads only the finished bytes) and rides every row the // page renders — the assignee chips project real identity under the never-blocks law: a store read // that refuses degrades to the empty roster, never a failed page. -fn served_observation_daily_surface_over(instance: HostDashboardInstance, plan: RoadmapAuthorityProjection, observed: BeltWorkflowAttempts) -> ServedObservationSurface { - let launch = belt_launch_host_standing_for_page_over(instance: instance, plan: plan) +fn served_observation_daily_surface_over(instance: HostDashboardInstance, plan: RoadmapAuthorityProjection, observed: BeltWorkflowAttempts, launch: LaunchHostStanding) -> ServedObservationSurface { let standings = served_standings_after_snapshot_close(read: roadmap_standings_read(layout: roadmap_event_carrier_layout_for_instance(instance: instance))) let profiles = served_observation_profiles_for_instance(instance: instance) let page = match observed { @@ -299,11 +298,17 @@ fn served_observation_attempts_surface_for_instance(instance: HostDashboardInsta ServedObservationSurface { kind: AttemptsJson, status: belt_attempts_http_status(a: a), body: belt_attempts_observed_json(a: a) } } -fn served_observation_workflow_surface_for_instance(instance: HostDashboardInstance, observed: BeltWorkflowAttempts) -> ServedObservationSurface { +// The workflow wire names each attempt's compact view revision, and that view includes whether +// the attempt is stale, so the wire is given the same revision comparison the pages make. +fn served_observation_workflow_surface_for_instance(instance: HostDashboardInstance, observed: BeltWorkflowAttempts, launch: LaunchHostStanding) -> ServedObservationSurface { + let stale_nodes = match observed { + WorkflowAttemptsObserved { attempts } => stale_attempt_nodes(observations: served_attempt_revision_observations(instance: instance, attempts: attempts), current_revision_hex: served_current_revision_hex(launch: launch)) + WorkflowAttemptsRefused { reason: _ } => [] + } ServedObservationSurface { kind: WorkflowJson, status: belt_workflow_attempts_http_status(observed: observed), - body: belt_workflow_attempts_observed_json_for_instance(instance: instance, observed: observed), + body: belt_workflow_attempts_observed_json_for_instance(instance: instance, observed: observed, stale_nodes: stale_nodes), } } @@ -312,17 +317,20 @@ fn served_observation_sessions_surface_for_instance(instance: HostDashboardInsta ServedObservationSurface { kind: SessionsJson, status: belt_sessions_http_status(o: o), body: belt_sessions_observed_json(o: o) } } -// The workflow attempts are observed ONCE per tick and shared by the two surfaces that project them -// (one observing authority, two projections), so the walk over dispatch worktrees happens once. +// The workflow attempts are observed ONCE per tick and shared by the surfaces that project them +// (one observing authority, several projections), so the walk over dispatch worktrees happens +// once; the launch host standing is read once as well and handed to the three surfaces that read +// it, rather than read again by each. fn served_observation_snapshot_observe_over(instance: HostDashboardInstance, plan: RoadmapAuthorityProjection, observed_at: String) -> ServedObservationSnapshot { let observed = belt_workflow_attempts_observe_for_instance(instance: instance) + let launch = belt_launch_host_standing_for_page_over(instance: instance, plan: plan) ServedObservationSnapshot { observed_at: observed_at, surfaces: [ - served_observation_daily_surface_over(instance: instance, plan: plan, observed: observed), - served_observation_issue_query_surface(instance: instance, plan: plan, observed: observed), + served_observation_daily_surface_over(instance: instance, plan: plan, observed: observed, launch: launch), + served_observation_issue_query_surface(instance: instance, plan: plan, observed: observed, launch: launch), served_observation_attempts_surface_for_instance(instance: instance), - served_observation_workflow_surface_for_instance(instance: instance, observed: observed), + served_observation_workflow_surface_for_instance(instance: instance, observed: observed, launch: launch), served_observation_sessions_surface_for_instance(instance: instance), ], } @@ -760,8 +768,7 @@ fn served_observation_standing_response_for_instance(instance: HostDashboardInst } } -fn served_observation_issue_query_surface(instance: HostDashboardInstance, plan: RoadmapAuthorityProjection, observed: BeltWorkflowAttempts) -> ServedObservationSurface { - let launch = belt_launch_host_standing_for_page_over(instance: instance, plan: plan) +fn served_observation_issue_query_surface(instance: HostDashboardInstance, plan: RoadmapAuthorityProjection, observed: BeltWorkflowAttempts, launch: LaunchHostStanding) -> ServedObservationSurface { let attempts = match observed { WorkflowAttemptsObserved { attempts } => attempts WorkflowAttemptsRefused { reason: _ } => [] } let body = issue_query_snapshot_for_projection(proj: plan, attempts: attempts, attempts_refused: match observed { WorkflowAttemptsObserved { attempts: _ } => none WorkflowAttemptsRefused { reason } => Present { value: reason } }, diff --git a/dag/gunbc/roadmap/roadmap_style.dag b/dag/gunbc/roadmap/roadmap_style.dag index 3cc28229e46..57281cf5656 100644 --- a/dag/gunbc/roadmap/roadmap_style.dag +++ b/dag/gunbc/roadmap/roadmap_style.dag @@ -1,5 +1,4 @@ module gunbc.roadmap_style -import gunbc.site.status { status_palette } import gunbc.design.typography { interface_text_decls, InterfaceBody, InterfaceLabel, InterfaceMetadata, InterfaceHeading, InterfaceSectionHeading } import gunbc.roadmap_surface_style { roadmap_surface_rules } import gunbc.site.document { SiteStyles, SiteThemeDecls, site_styles_css } @@ -46,7 +45,13 @@ import gunbc.fleet_capacity_panel { } import gunbc.roadmap_component { dispatch_reserved_width, disclosure_mark_class, swatch_toggle_class, swatch_toggle_reserved_width, roadmap_row_height, roadmap_row_gutter, workflow_controls_class, node_activity_class, activity_summary_class, activity_detail_class, activity_ledger_class, activity_obligation_class, activity_evidence_class, activity_evidence_row_class, activity_round_strip_class, activity_round_read_class, activity_round_write_class, activity_round_unmeasured_class, activity_round_reason_class, activity_located_reason_class, session_facts_class, stage_strip_class, stage_segment_class, stage_live_class, activity_throughput_class, activity_line_prefix_class, dashboard_instance_class, node_attempt_stale_class } import extdeps.color.srgb { Srgb8 } -import extdeps.google.issue_tracker { issue_tracker_visual_tokens, issue_tracker_visual_tokens_dark, IssueTrackerVisualTokens, issue_results_row_tracks } +import extdeps.google.issue_tracker { issue_results_row_tracks } +import gunbc.site.element.palette { palette_decls } +import gunbc.site.element.tokens { element_token_decls } +import gunbc.site.element.build_meter { build_meter_rules } +import gunbc.site.element.status_chip { status_chip_rules } +import gunbc.site.element.field_list { field_list_rules } +import gunbc.site.element.priority_mark { priority_mark_rules } import std.integer { uint8_channel_inclusive_max_value_derived } import extdeps.accessibility.wcag_contrast { srgb8_relative_luminance_scaled_for_max, contrast_ratio_x100 } import gunbc.design.palette { Swatch, SwatchSet, SwatchSetForUse, swatch_filed, swatch_paint, swatches_of, for_use_combined_set } @@ -440,9 +445,11 @@ fn activity_round_strip_rules() -> List { ] } +// The issue page's rail shows the BuildMeter element, so the page carries that element's own +// rules, unchanged, and (in roadmap_styles) the element tokens they read. fn roadmap_rules() -> List { concat(roadmap_instance_rules(), - concat(figure_rules(), concat(activity_round_strip_rules(), concat(standing_chip_rules(), roadmap_behavior_rules(rows: roadmap_behaviors))))) + concat(figure_rules(), concat(activity_round_strip_rules(), concat(standing_chip_rules(), concat(roadmap_behavior_rules(rows: roadmap_behaviors), concat(build_meter_rules(), concat(status_chip_rules(), concat(priority_mark_rules(), field_list_rules())))))))) } // The instrument's base rule, with its rest-state paint DERIVED (composition slice 1): the ink/fill/border decls splice in from the family roster's rest member via state_rest_decl at the exact positions the authored order demands, so the rest-to-invite binding is spelled only in dispatch_state_family — the base rule can no longer disagree with the roster it used to restate. Geometry decls stay authored here; they are the button's own facts, not state functions. @@ -571,39 +578,28 @@ fn roadmap_instance_rules() -> List { // properties — the rules below reference the vars, so a re-capture retunes the surface in ONE // place. Only issue content rules consume these variables. The shared shell, identity // controls, and focus responses use theme roles and never fall back through this palette. -// BOTH SCHEMES (owner defect fix 2026-09-23): the capture is light-only, and dark mode painted -// the light tokens on dark ground. The same token set now resolves under BOTH schemes, emitted -// with the theme machinery's own three-block grammar (:root base, [data-theme] override, -// prefers-color-scheme for the un-set case) — one token structure, two scheme values, never a -// second palette structure. A token declared in one scheme but not the other would strand the -// surface when the toggle flips; the witness holds the two decl sets identical in NAME. -fn issue_token_decls(t: IssueTrackerVisualTokens) -> List { - [ - decl(prop: CustomProperty { name: "issue-text" }, value: swatch_paint(sw: Swatch { name: "issue-text", color: t.text_primary })), - decl(prop: CustomProperty { name: "issue-text-dim" }, value: swatch_paint(sw: Swatch { name: "issue-text-dim", color: t.text_secondary })), - decl(prop: CustomProperty { name: "issue-border" }, value: swatch_paint(sw: Swatch { name: "issue-border", color: t.border_default })), - decl(prop: CustomProperty { name: "issue-bg-hover" }, value: swatch_paint(sw: Swatch { name: "issue-bg-hover", color: t.background_hover })), - decl(prop: CustomProperty { name: "issue-bg-subtle" }, value: swatch_paint(sw: Swatch { name: "issue-bg-subtle", color: t.background_subtle })), - decl(prop: CustomProperty { name: "issue-highlight" }, value: swatch_paint(sw: Swatch { name: "issue-highlight", color: t.highlight })), - decl(prop: CustomProperty { name: "issue-action" }, value: swatch_paint(sw: Swatch { name: "issue-action", color: t.action })), - decl(prop: CustomProperty { name: "issue-action-hover" }, value: swatch_paint(sw: Swatch { name: "issue-action-hover", color: t.action_hover })), - decl(prop: CustomProperty { name: "issue-error" }, value: swatch_paint(sw: Swatch { name: "issue-error", color: t.error })), +// BOTH SCHEMES: the palette is gunbc.site.element.palette, whose two scheme functions answer +// every slot, so a token cannot exist in one scheme and be missing in the other. The dark values +// are the captured dark palette of 2026-10-04 where the capture reached and the owner-directed +// 2026-09-23 values where it did not; that module says which is which. +fn issue_token_decls(night: Bool) -> List { + concat(palette_decls(night: night), [ decl(prop: CustomProperty { name: "fabric-badge-bg" }, value: swatch_paint(sw: robot_mark_badge_background)), decl(prop: CustomProperty { name: "fabric-badge-ink" }, value: swatch_paint(sw: robot_mark_ink)), decl(prop: CustomProperty { name: "issue-font" }, value: scale_var(t: font_sans)), decl(prop: CustomProperty { name: "issue-font-heading" }, value: scale_var(t: font_sans)), - ] + ]) } fn issue_tracker_root_css() -> String { join([ - css_block(selector: ":root", decls: issue_token_decls(t: issue_tracker_visual_tokens)), + css_block(selector: ":root", decls: issue_token_decls(night: false)), css_block( selector: join([":root[data-theme=\"", night_theme.name, "\"]"], ""), - decls: issue_token_decls(t: issue_tracker_visual_tokens_dark)), + decls: issue_token_decls(night: true)), join([ "@media (prefers-color-scheme: ", night_theme.scheme, ") { ", - css_block(selector: ":root:not([data-theme])", decls: issue_token_decls(t: issue_tracker_visual_tokens_dark)), + css_block(selector: ":root:not([data-theme])", decls: issue_token_decls(night: true)), " }", ], ""), ], "\n") @@ -1063,13 +1059,6 @@ fn roadmap_instance_rules_head() -> List { role_decl(prop: Background, r: SurfaceRole), ] }, - StaticRule { selector: ClassSelector { name: "issue-link" }, decls: [ - decl(prop: Color, value: "var(--issue-action-hover)"), - decl(prop: TextDecoration, value: "none"), - ] }, - ResponseRule { subject: RawSelector { text: ".issue-link" }, shape: SelfResponse, trigger: OnHover, transition_props: "background-color, color, outline-color", timing: respond_fast, base_decls: [], response_decls: [ - decl(prop: TextDecoration, value: "underline") - ] }, StaticRule { selector: ClassSelector { name: "issue-work-history" }, decls: [ decl(prop: Display, value: "flex"), decl(prop: FlexDirection, value: "column"), @@ -1119,15 +1108,6 @@ fn roadmap_instance_rules_head() -> List { - StaticRule { selector: ClassSelector { name: "issue-rail-link" }, decls: [ - decl(prop: FontFamily, value: "inherit"), - decl(prop: FontSize, value: "inherit"), - decl(prop: Color, value: "var(--issue-action-hover)"), - decl(prop: Background, value: "transparent"), - decl(prop: Border, value: "0"), - decl(prop: Padding, value: scale_var(t: space_0)), - decl(prop: Cursor, value: "pointer"), - ] }, StaticRule { selector: ClassSelector { name: "issue-assignee-text" }, decls: [ decl(prop: MarginLeft, value: scale_var(t: space_4)), decl(prop: FontSize, value: scale_var(t: text_13)), @@ -1675,9 +1655,9 @@ fn roadmap_styles() -> SiteStyles { SiteStyles { rules: concat(roadmap_rules(), flat_map(audition_sets, a => if audition_set_resolved(a: a) { [StaticRule { selector: RawSelector { text: join([":root[data-swatch-set=\"", a.set_name, "\"]"], "") }, decls: band_var_decls(rows: audition_paints(a: a)) }] } else { [] })), themes: [ - SiteThemeDecls { day: status_palette(night: false), night: status_palette(night: true) }, - SiteThemeDecls { day: issue_token_decls(t: issue_tracker_visual_tokens), night: issue_token_decls(t: issue_tracker_visual_tokens_dark) }, + SiteThemeDecls { day: issue_token_decls(night: false), night: issue_token_decls(night: true) }, SiteThemeDecls { day: band_var_decls(rows: workspace_band_paints()), night: band_var_decls(rows: workspace_band_paints()) }, + SiteThemeDecls { day: element_token_decls(), night: element_token_decls() }, ] } } fn roadmap_css() -> String { site_styles_css(styles: roadmap_styles()) } diff --git a/dag/gunbc/roadmap/roadmap_surface_style.dag b/dag/gunbc/roadmap/roadmap_surface_style.dag index 3ffea9d4abe..17421d0adc5 100644 --- a/dag/gunbc/roadmap/roadmap_surface_style.dag +++ b/dag/gunbc/roadmap/roadmap_surface_style.dag @@ -7,6 +7,9 @@ import extdeps.languages.css.selectors { RawSelector } import extdeps.languages.css.properties { CustomProperty, AlignItems, Background, Border, BorderBottom, BorderLeft, BorderRadius, BoxShadow, Color, Cursor, Display, Fill, Flex, FlexDirection, FlexWrap, FontFamily, FontSize, FontWeight, Gap, GridColumn, GridTemplateColumns, Height, Inset, JustifyContent, LetterSpacing, LineHeight, ListStyle, Margin, MarginBottom, MarginLeft, MarginRight, MarginTop, MaxWidth, MinHeight, MinWidth, Opacity, Outline, Overflow, Padding, PaddingLeft, Position, Stroke, TextDecoration, TextTransform, Transform, WhiteSpace, Width, RawProperty } import gunbc.design.scale { scale_var, scale_pair, scale_triple, scale_border, height_control, control_geometry, CompactChoice, RichIdentity, space_0, space_1, space_2, space_4, space_6, space_8, space_12, space_16, space_24, text_11, text_12, text_13, text_14, text_15, text_18, font_sans, font_mono, radius_surface, radius_999, border_1, border_2, width_status, height_compact_face } import gunbc.design.theme { role_decl, TextRole, TextDimRole, SurfaceRole, CanvasRole, FocusRole } +import gunbc.site.element.tokens { field_rail_width, field_rail_inset, field_rail_divider } +import gunbc.site.element.palette { palette_var, SlotBorder, SlotSurface } +import gunbc.site.element.priority_mark { priority_mark_label_inset } // Local control geometry needs wider priority badges and an intrinsic modified heading. // App ordering is distinct from the observed upstream roster. Header, rows and tracks share it. @@ -110,26 +113,10 @@ fn roadmap_surface_rules() -> List { StaticRule { selector: RawSelector { text: ".issue-cell-priority" }, decls: [ decl(prop: Display, value: "flex"), decl(prop: AlignItems, value: "center"), - decl(prop: JustifyContent, value: "center") - ] }, - StaticRule { selector: RawSelector { text: ".issue-priority" }, decls: [ - decl(prop: Display, value: "inline-flex"), - decl(prop: AlignItems, value: "center"), - decl(prop: JustifyContent, value: "center"), - decl(prop: MinWidth, value: scale_var(t: height_compact_face)), - decl(prop: Height, value: scale_var(t: height_compact_face)), - decl(prop: LineHeight, value: "1"), - decl(prop: FontSize, value: scale_var(t: text_12)), - decl(prop: FontWeight, value: "500"), - decl(prop: BorderRadius, value: scale_var(t: radius_surface)), - role_decl(prop: Color, r: TextDimRole) - ] }, - StaticRule { selector: RawSelector { text: ".issue-priority[data-priority=\"0\"]" }, decls: [ - decl(prop: Color, value: "var(--surface)"), - decl(prop: Background, value: "var(--issue-error)") + decl(prop: JustifyContent, value: "flex-start") ] }, - StaticRule { selector: RawSelector { text: ".issue-priority[data-priority=\"1\"]" }, decls: [ - decl(prop: Color, value: "var(--issue-error)") + StaticRule { selector: RawSelector { text: ".issue-col.issue-col-priority" }, decls: [ + decl(prop: RawProperty { name: "padding-left" }, value: join(["calc(", scale_var(t: space_8), " + ", priority_mark_label_inset(), ")"], "")) ] }, StaticRule { selector: RawSelector { text: ".issue-empty" }, decls: [ decl(prop: Display, value: "none"), @@ -149,22 +136,18 @@ fn roadmap_surface_rules() -> List { decl(prop: Display, value: "flex"), decl(prop: FlexDirection, value: "column"), decl(prop: Gap, value: "var(--space-8)"), - decl(prop: Flex, value: "3 1 50ch"), + decl(prop: Flex, value: "999 1 50ch"), decl(prop: MinWidth, value: "0"), decl(prop: MaxWidth, value: "100%") ] }, StaticRule { selector: RawSelector { text: ".issue-rail" }, decls: [ - decl(prop: Display, value: "flex"), - decl(prop: FlexDirection, value: "column"), - decl(prop: Width, value: "auto"), + decl(prop: Flex, value: join(["1 0 ", scale_var(t: field_rail_width)], "")), + decl(prop: MinWidth, value: "0"), decl(prop: MaxWidth, value: "100%"), - decl(prop: BorderRadius, value: "var(--radius-surface)"), - decl(prop: Border, value: "var(--border-1) solid var(--issue-border)"), - decl(prop: Flex, value: "0 1 34ch"), - decl(prop: MinWidth, value: "min(100%, 28ch)"), - decl(prop: Padding, value: "var(--space-16)"), - decl(prop: Gap, value: "var(--space-6)"), - decl(prop: Background, value: "var(--surface)") + decl(prop: Padding, value: scale_pair(a: space_8, b: field_rail_inset)), + decl(prop: BorderLeft, value: scale_border(w: field_rail_divider, color_var: palette_var(slot: SlotBorder))), + decl(prop: Background, value: palette_var(slot: SlotSurface)), + decl(prop: RawProperty { name: "align-self" }, value: "stretch") ] }, StaticRule { selector: RawSelector { text: ".issue-deps-section" }, decls: [ decl(prop: Display, value: "flex"), @@ -213,27 +196,6 @@ fn roadmap_surface_rules() -> List { decl(prop: MarginBottom, value: "var(--space-12)"), decl(prop: RawProperty { name: "padding-bottom" }, value: "var(--space-12)") ] }, - StaticRule { selector: RawSelector { text: ".issue-field" }, decls: [ - decl(prop: Display, value: "flex"), - decl(prop: Gap, value: scale_var(t: space_8)), - decl(prop: FontSize, value: scale_var(t: text_13)), - decl(prop: MinWidth, value: "0") - ] }, - StaticRule { selector: RawSelector { text: ".issue-field-label" }, decls: [ - decl(prop: MinWidth, value: scale_var(t: width_status)), - decl(prop: Flex, value: "none"), - decl(prop: Color, value: "var(--issue-text-dim)"), - decl(prop: FontSize, value: scale_var(t: text_11)), - decl(prop: FontFamily, value: "var(--issue-font, system-ui), sans-serif"), - decl(prop: LetterSpacing, value: "normal") - ] }, - StaticRule { selector: RawSelector { text: ".issue-field-value" }, decls: [ - decl(prop: Flex, value: "1"), - decl(prop: MinWidth, value: "0"), - decl(prop: RawProperty { name: "overflow-wrap" }, value: "anywhere"), - decl(prop: FontFamily, value: "var(--issue-font, system-ui), sans-serif"), - decl(prop: LetterSpacing, value: "normal") - ] }, StaticRule { selector: RawSelector { text: ".issue-action" }, decls: [ decl(prop: FontFamily, value: "var(--issue-font)"), decl(prop: FontSize, value: scale_var(t: text_13)), @@ -859,10 +821,6 @@ fn roadmap_surface_rules() -> List { decl(prop: MaxWidth, value: "var(--measure-prose)"), decl(prop: RawProperty { name: "overflow-wrap" }, value: "anywhere"), ], interface_text_decls(role: InterfaceProse)) }, - StaticRule { selector: RawSelector { text: ".issue-field-row" }, decls: [ - decl(prop: MarginBottom, value: "var(--space-8)"), - decl(prop: MinWidth, value: "0") - ] }, StaticRule { selector: RawSelector { text: ".fleet-hardware-panel" }, decls: [ decl(prop: MaxWidth, value: "100%"), decl(prop: MinWidth, value: "0"), @@ -877,14 +835,6 @@ fn roadmap_surface_rules() -> List { decl(prop: Color, value: "var(--issue-text-dim)"), decl(prop: Padding, value: "var(--space-20)") ] }, - StaticRule { selector: RawSelector { text: ".issue-status-detail summary" }, decls: [ - decl(prop: Cursor, value: "pointer"), - decl(prop: FontWeight, value: "600"), - decl(prop: Width, value: "fit-content"), - decl(prop: Padding, value: "var(--space-4) var(--space-8)"), - decl(prop: BorderRadius, value: "var(--radius-surface)"), - decl(prop: Background, value: "transparent") - ] }, StaticRule { selector: RawSelector { text: ".issue-status-facts" }, decls: [ decl(prop: Padding, value: "var(--space-12) 0"), decl(prop: FontSize, value: "var(--text-13)"), @@ -1116,33 +1066,6 @@ fn roadmap_surface_rules() -> List { StaticRule { selector: RawSelector { text: ".issue-membership-children[open] > summary::after" }, decls: [ decl(prop: Transform, value: "rotate(225deg)") ] }, - StaticRule { selector: RawSelector { text: ".issue-assignee-disclosure" }, decls: [ - decl(prop: Width, value: "100%"), - decl(prop: MinWidth, value: "0") - ] }, - StaticRule { selector: RawSelector { text: ".issue-assignee-disclosure > summary" }, decls: [ - decl(prop: Padding, value: "var(--space-4)"), - decl(prop: Gap, value: "var(--space-12)"), - decl(prop: Background, value: "transparent"), - decl(prop: MinWidth, value: "0") - ] }, - ResponseRule { subject: RawSelector { text: ".issue-assignee-disclosure > summary" }, shape: SelfResponse, trigger: OnHover, transition_props: "background-color, color, outline-color", timing: respond_fast, base_decls: [], response_decls: [ - decl(prop: Background, value: "var(--issue-bg-hover)") - ] }, - StaticRule { selector: RawSelector { text: ".issue-assignee-disclosure > summary .site-avatar" }, decls: [ - decl(prop: Width, value: "var(--h-compact-face)"), - decl(prop: Height, value: "var(--h-compact-face)"), - decl(prop: BorderRadius, value: "var(--radius-surface)"), - decl(prop: RawProperty { name: "flex-shrink" }, value: "0") - ] }, - StaticRule { selector: RawSelector { text: ".issue-assignee-identity" }, decls: [ - decl(prop: MinWidth, value: "0"), - decl(prop: RawProperty { name: "overflow-wrap" }, value: "anywhere"), - decl(prop: FontSize, value: "var(--text-13)") - ] }, - StaticRule { selector: RawSelector { text: ".issue-assignee-disclosure[open] > summary" }, decls: [ - decl(prop: Display, value: "none") - ] }, StaticRule { selector: RawSelector { text: ".issue-membership-children > summary.issue-membership-current" }, decls: [ decl(prop: GridTemplateColumns, value: "var(--space-16) minmax(0, 1fr) auto var(--space-12)"), decl(prop: Width, value: "100%") @@ -1155,35 +1078,27 @@ fn roadmap_surface_rules() -> List { decl(prop: Color, value: "var(--issue-text-dim)"), decl(prop: FontSize, value: "var(--text-12)") ] }, - StaticRule { selector: RawSelector { text: ".issue-rail .issue-field" }, decls: [ - decl(prop: Display, value: "grid"), - decl(prop: GridTemplateColumns, value: "10ch minmax(0, 1fr)"), - decl(prop: Gap, value: "var(--space-8)"), - decl(prop: AlignItems, value: "baseline"), - decl(prop: FontSize, value: "var(--text-13)") - ] }, - StaticRule { selector: RawSelector { text: ".issue-rail .issue-field:has(details)" }, decls: [ - decl(prop: AlignItems, value: "center") - ] }, - StaticRule { selector: RawSelector { text: ".issue-rail .issue-field-label" }, decls: [ - decl(prop: MinWidth, value: "0"), - decl(prop: FontFamily, value: "var(--font-sans)"), + StaticRule { selector: RawSelector { text: ".work-attempt-failures" }, decls: [ + decl(prop: Margin, value: "var(--space-4) 0 0"), + decl(prop: Padding, value: "0"), + decl(prop: ListStyle, value: "none"), + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: Gap, value: "var(--space-2)"), decl(prop: FontSize, value: "var(--text-12)"), + decl(prop: Color, value: "var(--issue-text-dim)"), + decl(prop: Width, value: "100%") ] }, - StaticRule { selector: RawSelector { text: ".issue-rail .issue-assignee-disclosure > summary" }, decls: [ - decl(prop: Padding, value: "var(--space-4) 0"), - decl(prop: Gap, value: "var(--space-8)") + StaticRule { selector: RawSelector { text: ".work-attempt-failure" }, decls: [ + decl(prop: BorderLeft, value: "var(--border-2) solid var(--issue-error)"), + decl(prop: PaddingLeft, value: "var(--space-6)") ] }, - - StaticRule { selector: RawSelector { text: ".issue-rail .issue-status-detail > summary" }, decls: [ - decl(prop: Position, value: "relative"), - decl(prop: RawProperty { name: "isolation" }, value: "isolate"), - decl(prop: Background, value: "transparent"), - decl(prop: Padding, value: "var(--space-4) 0"), - decl(prop: FontSize, value: "var(--text-13)"), - decl(prop: MinHeight, value: "var(--h-control)"), + StaticRule { selector: RawSelector { text: ".work-attempt-failure:target" }, decls: [ + decl(prop: Background, value: "var(--issue-highlight)"), + decl(prop: Color, value: "var(--issue-text)") ] }, + StaticRule { selector: RawSelector { text: ".issue-reading-surface" }, decls: [ decl(prop: Border, value: "var(--border-1) solid var(--issue-border)"), decl(prop: BorderRadius, value: "var(--radius-surface)"), diff --git a/dag/gunbc/roadmap/roadmap_workflow_progress.dag b/dag/gunbc/roadmap/roadmap_workflow_progress.dag index 666df0f50ce..987199e88da 100644 --- a/dag/gunbc/roadmap/roadmap_workflow_progress.dag +++ b/dag/gunbc/roadmap/roadmap_workflow_progress.dag @@ -1,6 +1,6 @@ module gunbc.roadmap_workflow_progress -import std.types { String, Int, Bool, List } +import std.types { String, Int, Bool, List, NonEmptyStr } import extdeps.filesystem.filesystem_io { FilesystemFileObservation, FilesystemFileAbsent, FilesystemFileRead, FilesystemFileIndeterminate, FilesystemFileObservationsDisagree, FilesystemFileSubjectRefused, } @@ -35,7 +35,7 @@ import gunbc.roadmap_provider_events { provider_execution_detail, parse_codex_jsonl, } -import gunbc.harness.harness_throughput { HarnessRoundLedger, throughput_cell_text, harness_throughput_from_ledger } +import gunbc.harness.harness_throughput { HarnessRoundLedger, RoundsUnobserved, throughput_cell_text, harness_throughput_from_ledger, harness_round_ledger_from_events } import gunbc.roadmap.roadmap_review_role { CriterionReviewEvidence } import gunbc.roadmap.roadmap_review_function { ReviewReport } import gunbc.roadmap_session_placement { SessionPlacementObservation } @@ -1362,6 +1362,26 @@ fn workflow_attempt_reconciliation( ) } +// THE ROUND LEDGER IS READ ONLY FROM A STREAM THAT CAPTURED ROUNDS. An attempt that predates +// modeled admission predates round capture as well, an attempt whose standing could not be read +// cannot say which it is, and a stream that could not be read records nothing: all three are rounds +// this instrumentation did not observe, each said with its own reason, never a stream that holds no +// round (which is what a new attempt's readable, empty stream is). +fn workflow_attempt_round_ledger(evidence: WorkflowAttemptEvidence) -> HarnessRoundLedger { + match evidence.modeled_state { + WorkflowModeledStateAbsent => + RoundsUnobserved { reason: "the attempt predates round capture, so its rounds were not recorded" as NonEmptyStr } + WorkflowModeledStateUnreadable { cause } => + RoundsUnobserved { reason: join(["the attempt's standing is unknown because its current-attempt-key pointer could not be read, so its rounds were not read: ", cause], "") as NonEmptyStr } + WorkflowModeledStateObserved => + if !evidence.provider_events_readable { + RoundsUnobserved { reason: "the attempt's provider event stream is unreadable, so its rounds were not read" as NonEmptyStr } + } else { + harness_round_ledger_from_events(body: evidence.provider_events) + } + } +} + fn workflow_attempt_progress( evidence: WorkflowAttemptEvidence, ) -> WorkflowAttemptProgress { @@ -1391,7 +1411,7 @@ fn workflow_attempt_progress( evidence: evidence, segments: segments, ), - rounds: harness_round_ledger_from_events(body: evidence.provider_events), + rounds: workflow_attempt_round_ledger(evidence: evidence), handoff: workflow_attempt_handoff_state(provider: provider, evidence: evidence), } } diff --git a/dag/gunbc/site/build_meter_client.dag b/dag/gunbc/site/build_meter_client.dag new file mode 100644 index 00000000000..878cbaf20a3 --- /dev/null +++ b/dag/gunbc/site/build_meter_client.dag @@ -0,0 +1,278 @@ +module gunbc.site.build_meter_client + +import std.types { Int, List, String } +import extdeps.languages.typescript.program { + TsExpr, TsStmt, ts_ident, ts_lit_str, ts_lit_num, ts_member, ts_call, ts_arrow, ts_block, ts_const, ts_let, + ts_return, ts_expr_stmt, ts_if, ts_cond, ts_prefix, ts_binop, ts_for_of, ts_spread, +} +import gunbc.site.element.build_meter { + build_meter_class, build_meter_worker_class, build_meter_look_attribute, build_meter_look_left, build_meter_look_right, + build_meter_drowsy_class, build_meter_asleep_class, build_meter_waking_class, build_meter_beat_class, build_meter_beat_timing, + build_meter_canvas_class, build_meter_changing_class, build_meter_change_ms, + build_meter_scene_attribute, build_meter_settle_attribute, build_meter_stance_attribute, build_meter_worker_x_attribute, + build_meter_rounds_attribute, build_meter_side_attribute, build_meter_from_attribute, build_meter_to_attribute, + build_meter_walk_property, build_meter_walk_steps_property, build_meter_from_width_property, build_meter_to_width_property, + BuildMeterSettleAtOnce, build_meter_settle_key, BuildMeterLeaving, BuildMeterArriving, build_meter_side_key, +} + +// The build meter's pointer decoration. It sets a look attribute and three state classes on the +// element and nothing else: the element's own rules (gunbc.site.element.build_meter) decide what +// they look like, and no fact about the attempt is read or written here. While the pointer moves +// over the meter the worker looks toward the pointer's side. A pointer that rests there makes it +// drowsy and then asleep; leaving wakes it slowly. Under prefers-reduced-motion nothing is set. +data build_meter_reduced_motion_query: String = "(prefers-reduced-motion: reduce)" +data build_meter_drowse_after_ms: Int = 4200 +data build_meter_sleep_after_ms: Int = 2600 +data build_meter_wake_ms: Int = 1000 + +// THE METER TAKES A NEWER RENDERING OF ITSELF. The page's workflow observation dispatches this +// event on the meter with the rendering it read and accepted (gunbc.roadmap.roadmap_component). +// Every fact is taken at once: the body's attributes, the badge, the accessible name, the digest +// and the link destination. The drawing is drawn when its scene settles. A drawing of the scene +// already drawn replaces it at once, and the worker beats if the strip grew. A new scene is held +// until it has been observed without a break for the dwell, and a newer rendering back in the +// drawn scene discards it, so activity that flaps between observations is never drawn; a drawing +// that settles at once (the attempt stopped or published) is not held. A held scene then changes +// over build_meter_change_ms by the element's choreography rows, or at once under reduced motion. A +// rendering that arrives while a change plays waits for it to end. +data build_meter_fresh_event: String = "gunbc-meter-fresh" + +// The dwell is a reading duration, long enough that a scene seen in only one or two of the page's +// observations is never drawn and short enough that a real change is drawn within a few seconds. +data build_meter_dwell_ms: Int = 6000 + +fn build_meter_reduced_motion_expr() -> TsExpr { + ts_member(object: ts_call(callee: ts_member(object: ts_ident(name: "window"), property: "matchMedia"), args: [ts_lit_str(value: build_meter_reduced_motion_query)]), property: "matches") +} + +fn meter_class_call(method: String, class_name: String) -> TsExpr { + ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "meter"), property: "classList"), property: method), args: [ts_lit_str(value: class_name)]) +} + +fn meter_later(body: List, after_ms: Int) -> TsExpr { + ts_call(callee: ts_ident(name: "setTimeout"), args: [ts_arrow(params: [], body: ts_block(stmts: body)), ts_lit_num(text: to_string(after_ms))]) +} + +fn meter_listen(event: String, params: List, body: List) -> TsStmt { + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "meter"), property: "addEventListener"), args: [ts_lit_str(value: event), ts_arrow(params: params, body: ts_block(stmts: body))])) +} + +fn meter_assign(name: String, value: TsExpr) -> TsStmt { + ts_expr_stmt(expr: ts_binop(left: ts_ident(name: name), op: "=", right: value)) +} + +fn meter_method(object: String, method: String, args: List) -> TsExpr { + ts_call(callee: ts_member(object: ts_ident(name: object), property: method), args: args) +} + +fn meter_call_stmt(object: String, method: String, args: List) -> TsStmt { + ts_expr_stmt(expr: meter_method(object: object, method: method, args: args)) +} + +fn meter_attribute(object: String, attribute: String) -> TsExpr { + meter_method(object: object, method: "getAttribute", args: [ts_lit_str(value: attribute)]) +} + +fn meter_number(object: String, attribute: String) -> TsExpr { + ts_prefix(op: "+", expr: meter_attribute(object: object, attribute: attribute)) +} + +fn meter_math(method: String, args: List) -> TsExpr { + meter_method(object: "Math", method: method, args: args) +} + +fn meter_canvas_property(property: String, value: TsExpr) -> TsStmt { + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "canvas"), property: "style"), property: "setProperty"), args: [ts_lit_str(value: join(["--", property], "")), value])) +} + +fn meter_px(value: TsExpr) -> TsExpr { + ts_binop(left: value, op: "+", right: ts_lit_str(value: "px")) +} + +fn meter_return_unless(cond: TsExpr) -> TsStmt { + ts_if(cond: ts_prefix(op: "!", expr: cond), then_stmt: ts_block(stmts: [ts_return(expr: none)]), else_stmt: none) +} + +// The drawn drawing, read where it is drawn each time. +fn meter_drawn_stmts() -> List { + [ + ts_const(name: "canvas", value: meter_method(object: "meter", method: "querySelector", args: [ts_lit_str(value: join([".", build_meter_canvas_class], ""))])), + ts_const(name: "drawn", value: ts_binop(left: ts_ident(name: "canvas"), op: "&&", right: ts_member(object: ts_ident(name: "canvas"), property: "firstElementChild"))), + meter_return_unless(cond: ts_ident(name: "drawn")), + ] +} + +// The same scene, newer: replace the drawing, and beat once if the strip holds more rounds. +fn meter_redraw_stmt() -> TsStmt { + ts_const(name: "redraw", value: ts_arrow(params: ["canvas", "drawn", "art"], body: ts_block(stmts: [ + meter_call_stmt(object: "canvas", method: "replaceChild", args: [ts_ident(name: "art"), ts_ident(name: "drawn")]), + ts_if( + cond: ts_binop( + left: ts_binop(left: meter_number(object: "art", attribute: build_meter_rounds_attribute), op: ">", right: meter_number(object: "drawn", attribute: build_meter_rounds_attribute)), + op: "&&", + right: ts_prefix(op: "!", expr: build_meter_reduced_motion_expr())), + then_stmt: ts_block(stmts: [ + ts_expr_stmt(expr: meter_class_call(method: "add", class_name: build_meter_beat_class)), + ts_expr_stmt(expr: meter_later(body: [ts_expr_stmt(expr: meter_class_call(method: "remove", class_name: build_meter_beat_class))], after_ms: build_meter_beat_timing.duration_ms)), + ]), + else_stmt: none), + ]))) +} + +// A new scene: the arriving drawing goes under the drawn one, both marked with their side, and +// the canvas carries the two stances, the walk and the two widths while the change plays. +fn meter_change_stmt() -> TsStmt { + ts_const(name: "change", value: ts_arrow(params: ["art"], body: ts_block(stmts: concat(meter_drawn_stmts(), [ + ts_if(cond: build_meter_reduced_motion_expr(), then_stmt: ts_block(stmts: [ + meter_call_stmt(object: "canvas", method: "replaceChild", args: [ts_ident(name: "art"), ts_ident(name: "drawn")]), + ts_return(expr: none), + ]), else_stmt: none), + meter_assign(name: "changing", value: ts_ident(name: "true")), + ts_const(name: "walk", value: ts_binop(left: meter_number(object: "art", attribute: build_meter_worker_x_attribute), op: "-", right: meter_number(object: "drawn", attribute: build_meter_worker_x_attribute))), + meter_canvas_property(property: build_meter_walk_property, value: meter_px(value: ts_ident(name: "walk"))), + meter_canvas_property(property: build_meter_walk_steps_property, value: ts_call(callee: ts_ident(name: "String"), args: [ + meter_math(method: "max", args: [ts_lit_num(text: "1"), meter_math(method: "round", args: [ts_binop(left: meter_math(method: "abs", args: [ts_ident(name: "walk")]), op: "/", right: ts_lit_num(text: "2"))])]), + ])), + meter_canvas_property(property: build_meter_from_width_property, value: meter_px(value: meter_attribute(object: "drawn", attribute: "width"))), + meter_canvas_property(property: build_meter_to_width_property, value: meter_px(value: meter_attribute(object: "art", attribute: "width"))), + meter_call_stmt(object: "canvas", method: "setAttribute", args: [ts_lit_str(value: build_meter_from_attribute), meter_attribute(object: "drawn", attribute: build_meter_stance_attribute)]), + meter_call_stmt(object: "canvas", method: "setAttribute", args: [ts_lit_str(value: build_meter_to_attribute), meter_attribute(object: "art", attribute: build_meter_stance_attribute)]), + meter_call_stmt(object: "drawn", method: "setAttribute", args: [ts_lit_str(value: build_meter_side_attribute), ts_lit_str(value: build_meter_side_key(side: BuildMeterLeaving))]), + meter_call_stmt(object: "art", method: "setAttribute", args: [ts_lit_str(value: build_meter_side_attribute), ts_lit_str(value: build_meter_side_key(side: BuildMeterArriving))]), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "canvas"), property: "classList"), property: "add"), args: [ts_lit_str(value: build_meter_changing_class)])), + meter_call_stmt(object: "canvas", method: "insertBefore", args: [ts_ident(name: "art"), ts_ident(name: "drawn")]), + ts_expr_stmt(expr: meter_later(body: [ + meter_call_stmt(object: "drawn", method: "remove", args: []), + meter_call_stmt(object: "art", method: "removeAttribute", args: [ts_lit_str(value: build_meter_side_attribute)]), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "canvas"), property: "classList"), property: "remove"), args: [ts_lit_str(value: build_meter_changing_class)])), + meter_call_stmt(object: "canvas", method: "removeAttribute", args: [ts_lit_str(value: build_meter_from_attribute)]), + meter_call_stmt(object: "canvas", method: "removeAttribute", args: [ts_lit_str(value: build_meter_to_attribute)]), + meter_call_stmt(object: "canvas", method: "removeAttribute", args: [ts_lit_str(value: "style")]), + meter_assign(name: "changing", value: ts_ident(name: "false")), + ts_const(name: "next", value: ts_ident(name: "queued")), + meter_assign(name: "queued", value: ts_ident(name: "null")), + ts_if(cond: ts_ident(name: "next"), then_stmt: ts_block(stmts: [ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "present"), args: [ts_ident(name: "next")]))]), else_stmt: none), + ], after_ms: build_meter_change_ms)), + ])))) +} + +fn meter_scene_of(object: String) -> TsExpr { + meter_attribute(object: object, attribute: build_meter_scene_attribute) +} + +// Settle on a drawing: draw the same scene at once, and hold a new one for the dwell. +fn meter_present_stmt() -> TsStmt { + ts_const(name: "present", value: ts_arrow(params: ["art"], body: ts_block(stmts: concat([ + ts_if(cond: ts_ident(name: "changing"), then_stmt: ts_block(stmts: [meter_assign(name: "queued", value: ts_ident(name: "art")), ts_return(expr: none)]), else_stmt: none), + ], concat(meter_drawn_stmts(), [ + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "clearTimeout"), args: [ts_ident(name: "heldTimer")])), + ts_if(cond: ts_binop(left: meter_scene_of(object: "art"), op: "===", right: meter_scene_of(object: "drawn")), then_stmt: ts_block(stmts: [ + meter_assign(name: "held", value: ts_ident(name: "null")), + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "redraw"), args: [ts_ident(name: "canvas"), ts_ident(name: "drawn"), ts_ident(name: "art")])), + ts_return(expr: none), + ]), else_stmt: none), + ts_if( + cond: ts_binop(left: ts_prefix(op: "!", expr: ts_ident(name: "held")), op: "||", right: ts_binop(left: meter_scene_of(object: "held"), op: "!==", right: meter_scene_of(object: "art"))), + then_stmt: ts_block(stmts: [meter_assign(name: "heldSince", value: meter_method(object: "Date", method: "now", args: []))]), + else_stmt: none), + meter_assign(name: "held", value: ts_ident(name: "art")), + meter_assign(name: "heldTimer", value: ts_call(callee: ts_ident(name: "setTimeout"), args: [ + ts_arrow(params: [], body: ts_block(stmts: [ + ts_const(name: "next", value: ts_ident(name: "held")), + meter_assign(name: "held", value: ts_ident(name: "null")), + ts_if(cond: ts_ident(name: "next"), then_stmt: ts_block(stmts: [ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "change"), args: [ts_ident(name: "next")]))]), else_stmt: none), + ])), + ts_cond( + test: ts_binop(left: meter_attribute(object: "art", attribute: build_meter_settle_attribute), op: "===", right: ts_lit_str(value: build_meter_settle_key(settle: BuildMeterSettleAtOnce))), + consequent: ts_lit_num(text: "0"), + alternate: meter_math(method: "max", args: [ + ts_lit_num(text: "0"), + ts_binop(left: ts_binop(left: ts_ident(name: "heldSince"), op: "+", right: ts_lit_num(text: to_string(build_meter_dwell_ms))), op: "-", right: meter_method(object: "Date", method: "now", args: [])), + ])), + ])), + ]))))) +} + +// The newer rendering: its facts now, its drawing through present. +fn meter_fresh_listener_stmt() -> TsStmt { + meter_listen(event: build_meter_fresh_event, params: ["event"], body: [ + ts_const(name: "fresh", value: ts_member(object: ts_ident(name: "event"), property: "detail")), + ts_const(name: "body", value: ts_member(object: ts_ident(name: "meter"), property: "firstElementChild")), + ts_const(name: "freshBody", value: ts_binop(left: ts_ident(name: "fresh"), op: "&&", right: ts_member(object: ts_ident(name: "fresh"), property: "firstElementChild"))), + ts_const(name: "freshCanvas", value: ts_binop(left: ts_ident(name: "freshBody"), op: "&&", right: meter_method(object: "freshBody", method: "querySelector", args: [ts_lit_str(value: join([".", build_meter_canvas_class], ""))]))), + ts_const(name: "art", value: ts_binop(left: ts_ident(name: "freshCanvas"), op: "&&", right: ts_member(object: ts_ident(name: "freshCanvas"), property: "firstElementChild"))), + meter_return_unless(cond: ts_binop(left: ts_ident(name: "body"), op: "&&", right: ts_ident(name: "art"))), + meter_call_stmt(object: "freshCanvas", method: "remove", args: []), + ts_for_of(var_name: "name", iterable: meter_method(object: "freshBody", method: "getAttributeNames", args: []), body: ts_block(stmts: [ + meter_call_stmt(object: "body", method: "setAttribute", args: [ts_ident(name: "name"), meter_method(object: "freshBody", method: "getAttribute", args: [ts_ident(name: "name")])]), + ])), + ts_for_of(var_name: "old", iterable: meter_method(object: "Array", method: "from", args: [ts_member(object: ts_ident(name: "body"), property: "children")]), body: ts_block(stmts: [ + ts_if( + cond: ts_prefix(op: "!", expr: ts_call(callee: ts_member(object: ts_member(object: ts_ident(name: "old"), property: "classList"), property: "contains"), args: [ts_lit_str(value: build_meter_canvas_class)])), + then_stmt: ts_block(stmts: [meter_call_stmt(object: "old", method: "remove", args: [])]), + else_stmt: none), + ])), + meter_call_stmt(object: "body", method: "append", args: [ts_spread(expr: meter_method(object: "Array", method: "from", args: [ts_member(object: ts_ident(name: "freshBody"), property: "children")]))]), + meter_call_stmt(object: "meter", method: "setAttribute", args: [ts_lit_str(value: "href"), meter_method(object: "fresh", method: "getAttribute", args: [ts_lit_str(value: "href")])]), + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "present"), args: [ts_ident(name: "art")])), + ]) +} + +fn build_meter_bind_statements() -> List { + [ + ts_const(name: "siteBindBuildMeter", value: ts_arrow(params: ["meter"], body: ts_block(stmts: [ + ts_let(name: "drowse", value: ts_lit_num(text: "0")), + ts_let(name: "sleep", value: ts_lit_num(text: "0")), + ts_const(name: "rouse", value: ts_arrow(params: [], body: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "clearTimeout"), args: [ts_ident(name: "drowse")])), + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "clearTimeout"), args: [ts_ident(name: "sleep")])), + ts_const(name: "slept", value: ts_binop(left: meter_class_call(method: "contains", class_name: build_meter_drowsy_class), op: "||", right: meter_class_call(method: "contains", class_name: build_meter_asleep_class))), + ts_expr_stmt(expr: meter_class_call(method: "remove", class_name: build_meter_drowsy_class)), + ts_expr_stmt(expr: meter_class_call(method: "remove", class_name: build_meter_asleep_class)), + ts_return(expr: Present { value: ts_ident(name: "slept") }), + ]))), + meter_listen(event: "pointermove", params: ["event"], body: [ + ts_if(cond: build_meter_reduced_motion_expr(), then_stmt: ts_block(stmts: [ts_return(expr: none)]), else_stmt: none), + ts_const(name: "worker", value: ts_call(callee: ts_member(object: ts_ident(name: "meter"), property: "querySelector"), args: [ts_lit_str(value: join([".", build_meter_worker_class], ""))])), + ts_if(cond: ts_prefix(op: "!", expr: ts_ident(name: "worker")), then_stmt: ts_block(stmts: [ts_return(expr: none)]), else_stmt: none), + ts_const(name: "box", value: ts_call(callee: ts_member(object: ts_ident(name: "worker"), property: "getBoundingClientRect"), args: [])), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "meter"), property: "setAttribute"), args: [ + ts_lit_str(value: build_meter_look_attribute), + ts_cond( + test: ts_binop( + left: ts_member(object: ts_ident(name: "event"), property: "clientX"), + op: "<", + right: ts_binop(left: ts_member(object: ts_ident(name: "box"), property: "left"), op: "+", right: ts_binop(left: ts_member(object: ts_ident(name: "box"), property: "width"), op: "/", right: ts_lit_num(text: "2")))), + consequent: ts_lit_str(value: build_meter_look_left), + alternate: ts_lit_str(value: build_meter_look_right)), + ])), + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "rouse"), args: [])), + meter_assign(name: "drowse", value: meter_later(body: [ + ts_expr_stmt(expr: meter_class_call(method: "add", class_name: build_meter_drowsy_class)), + meter_assign(name: "sleep", value: meter_later(body: [ + ts_expr_stmt(expr: meter_class_call(method: "add", class_name: build_meter_asleep_class)), + ], after_ms: build_meter_sleep_after_ms)), + ], after_ms: build_meter_drowse_after_ms)), + ]), + meter_listen(event: "pointerleave", params: [], body: [ + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "meter"), property: "removeAttribute"), args: [ts_lit_str(value: build_meter_look_attribute)])), + ts_if(cond: ts_call(callee: ts_ident(name: "rouse"), args: []), then_stmt: ts_block(stmts: [ + ts_expr_stmt(expr: meter_class_call(method: "add", class_name: build_meter_waking_class)), + ts_expr_stmt(expr: meter_later(body: [ + ts_expr_stmt(expr: meter_class_call(method: "remove", class_name: build_meter_waking_class)), + ], after_ms: build_meter_wake_ms)), + ]), else_stmt: none), + ]), + ts_let(name: "held", value: ts_ident(name: "null")), + ts_let(name: "heldSince", value: ts_lit_num(text: "0")), + ts_let(name: "heldTimer", value: ts_lit_num(text: "0")), + ts_let(name: "changing", value: ts_ident(name: "false")), + ts_let(name: "queued", value: ts_ident(name: "null")), + meter_redraw_stmt(), + meter_change_stmt(), + meter_present_stmt(), + meter_fresh_listener_stmt(), + ]))), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_call(callee: ts_member(object: ts_ident(name: "Array"), property: "from"), args: [ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "querySelectorAll"), args: [ts_lit_str(value: join([".", build_meter_class], ""))])]), property: "forEach"), args: [ts_ident(name: "siteBindBuildMeter")])), + ] +} diff --git a/dag/gunbc/site/controls.dag b/dag/gunbc/site/controls.dag index 2cf9cb6242c..3518e2b9ea3 100644 --- a/dag/gunbc/site/controls.dag +++ b/dag/gunbc/site/controls.dag @@ -1,6 +1,8 @@ module gunbc.site.controls import gunbc.site.mention_client { mention_bind_statements } import gunbc.site.choice_client { choice_bind_statements } +import gunbc.site.build_meter_client { build_meter_bind_statements } +import gunbc.site.specimen_meter_client { specimen_meter_bind_statements } import std.types { Int, String, Bool, Milliseconds } import std.measure { Millisecond, millisecond, millisecond_count } @@ -523,6 +525,6 @@ data site_client_path: String = "/assets/site.js" fn site_client_program() -> TsProgram { let dismiss = ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "gunbcSessionRead"), property: "catch"), args: [ts_arrow(params: [], body: ts_block(stmts: []))])) let statements = concat(sound_preamble_statements(), concat(sound_toggle_bind_statements(), concat(theme_toggle_bind_statements(), concat(session_read_bind_statements(), concat(account_swap_statements(), [dismiss]))))) - TsProgram { ts_nocheck: false, header_lines: [], statements: concat(statements, concat(choice_bind_statements(), mention_bind_statements())) } + TsProgram { ts_nocheck: false, header_lines: [], statements: concat(statements, concat(choice_bind_statements(), concat(mention_bind_statements(), concat(build_meter_bind_statements(), specimen_meter_bind_statements())))) } } fn site_client_source() -> String { serialize_typescript_program(p: site_client_program()) } diff --git a/dag/gunbc/site/element/build_meter.dag b/dag/gunbc/site/element/build_meter.dag new file mode 100644 index 00000000000..9caace3dd0f --- /dev/null +++ b/dag/gunbc/site/element/build_meter.dag @@ -0,0 +1,1746 @@ +module gunbc.site.element.build_meter + +import std.types { Bool, Int, List, String } +import std.markup { Fragment, Element, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.design.material { + BuildRule, CssDecl, StaticRule, ResponseRule, AmbientRule, SelfResponse, WithinSubject, + OnHover, OnFocusVisible, OnStateClass, MotionCycle, Keyframe, TimingToken, RunOnce, RunForever, + respond_fast, restore_gentle, decl, +} +import gunbc.design.scale { scale_var, scale_pair, scale_border, space_0, space_1, space_2, space_4, border_1, border_2 } +import gunbc.site.element.tokens { radius_element } +import gunbc.site.robot_mark { robot_mark_body, robot_mark_left_eye, robot_mark_mouth, robot_mark_body_scale_tenths } +import gunbc.site.element.palette { + PaletteSlot, palette_var, SlotAction, SlotBorder, SlotError, SlotHover, SlotSurface, SlotTextDim, + SlotMeterReason, SlotMeterRead, SlotMeterWrite, SlotMeterEdit, SlotMeterRun, SlotMeterSearch, SlotMeterReport, + SlotMeterWorker, SlotMeterWorkerLight, SlotMeterWorkerShade, SlotMeterWorkerFeature, SlotMeterFinish, SlotMeterSuccess, +} +import gunbc.site.element.text { element_text_decls, ElementLabel } +import gunbc.site.element.touch_target { touch_target_rule, TouchTargetCentredLink } +import gunbc.roadmap_workflow_stage { + WorkflowSegmentKind, WorkflowSegmentState, workflow_segment_kind_label, workflow_segment_state_key, +} +import gunbc.workflow_reconcile { WorkflowFactKey } +import gunbc.roadmap.roadmap_submission { + CandidateHandoffState, HandoffAwaitingWorker, HandoffYielded, HandoffEvidenceRefused, HandoffDeclaredBlocked, + HandoffSubmitted, HandoffVerificationFailed, HandoffAwaitingIntegration, HandoffBlockedOnIntegration, + HandoffPublicationOwed, HandoffBlockedOnPublication, HandoffPublished, submission_readiness_key, +} +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Animation, AnimationDelay, AnimationDuration, Background, BorderRadius, BoxShadow, ClipPath, Display, Fill, Gap, + GridArea, Height, Margin, MaxWidth, Opacity, Outline, Overflow, Padding, Position, TextDecoration, Transform, WhiteSpace, Width, + RawProperty, +} + +// BuildMeter: one attempt's recorded work as a quiet visual link. It draws a strip of the recent +// rounds, one small gunbai worker at the working edge and a mark for how the attempt stands, and +// it shows no prose: the facts are carried by the link's accessible name, by a one-line digest +// shown on hover or focus, and by the Work tab the link opens. It takes a CompactWorkSummary, the +// value the producer (gunbc.roadmap.roadmap_build_meter compact_work_summary) derives from one +// attempt, and draws only that: no polling, no workflow rule, no definition of success. +// +// Three facts stay separate in the summary and in the drawing. Coverage says what the round +// record holds: rounds, none yet, or not recorded by this instrumentation. Outcome says how the +// attempt stands: working, waiting, failed (with a digest naming the event to open), published. +// Staleness says the attempt was launched on a revision the deployed tree has moved past. None is +// inferred from another; a refused attempt with unrecorded rounds shows both. +// +// The drawing is complete with scripting off and under reduced motion. Motion is decoration over +// it (a slow blink, a glance, a nap, one beat when a round lands), carries no fact, and is removed +// under prefers-reduced-motion by the shared rule that stills every ambient cycle and transition. + +// The operation kinds a mark can have. The set is closed; a producer supplies only the kinds its +// records distinguish, and this element draws whichever arrive. +type BuildMeterKind + = BuildMeterReason + | BuildMeterRead + | BuildMeterWrite + | BuildMeterEdit + | BuildMeterRun + | BuildMeterSearch + | BuildMeterReport + +fn build_meter_kind_key(kind: BuildMeterKind) -> String { + match kind { + BuildMeterReason => "reason" + BuildMeterRead => "read" + BuildMeterWrite => "write" + BuildMeterEdit => "edit" + BuildMeterRun => "run" + BuildMeterSearch => "search" + BuildMeterReport => "report" + } +} + +fn build_meter_kind_slot(kind: BuildMeterKind) -> PaletteSlot { + match kind { + BuildMeterReason => SlotMeterReason + BuildMeterRead => SlotMeterRead + BuildMeterWrite => SlotMeterWrite + BuildMeterEdit => SlotMeterEdit + BuildMeterRun => SlotMeterRun + BuildMeterSearch => SlotMeterSearch + BuildMeterReport => SlotMeterReport + } +} + +// A kind's lane, counted up from the lane nearest the base. Each kind keeps one lane, so a +// mark's height in the strip says its kind as well as its colour does. +fn build_meter_kind_lane(kind: BuildMeterKind) -> Int { + match kind { + BuildMeterRun => 0 + BuildMeterWrite => 1 + BuildMeterEdit => 2 + BuildMeterRead => 3 + BuildMeterSearch => 4 + BuildMeterReason => 5 + BuildMeterReport => 6 + } +} + +// A write and an edit change the work itself, so their marks are drawn full at any count and lit +// along the top. +fn build_meter_kind_is_lit(kind: BuildMeterKind) -> Bool { + match kind { + BuildMeterWrite => true + BuildMeterEdit => true + BuildMeterReason => false + BuildMeterRead => false + BuildMeterRun => false + BuildMeterSearch => false + BuildMeterReport => false + } +} + +data build_meter_kinds: List = [ + BuildMeterRun, BuildMeterWrite, BuildMeterEdit, BuildMeterRead, BuildMeterSearch, BuildMeterReason, BuildMeterReport, +] + +// WHERE A MARK'S COUNT COMES FROM. A typed operation is a count of operations the producer that +// dispatched them recorded by kind. The other two are what the round ledger records today, and a +// mark built from them says so: a tool call count is calls of unrecorded kind, drawn in the run +// lane; a changed-path rise is how far the worktree's changed-path count rose over the round, an +// observation of the tree rather than a count of writes, drawn in the write lane. Both are legacy +// inferences that typed operations replace when the producer records them. +type BuildMeterEvidence + = BuildMeterTypedOperation + | BuildMeterToolCallCount + | BuildMeterChangedPathCountRise + +fn build_meter_evidence_key(evidence: BuildMeterEvidence) -> String { + match evidence { + BuildMeterTypedOperation => "typed" + BuildMeterToolCallCount => "tool-calls" + BuildMeterChangedPathCountRise => "changed-paths" + } +} + +type BuildMeterMark { + kind: BuildMeterKind + count: Int + evidence: BuildMeterEvidence +} + +type BuildMeterRound { + step: Int + marks: List + input_tokens: Int + output_tokens: Int +} + +// WHAT THE ROUND RECORD HOLDS, separately from how the attempt stands. No round yet is a record +// that was read and holds none; not recorded is an attempt whose rounds this instrumentation never +// captured. Neither says whether work happened. +type BuildMeterCoverage + = BuildMeterNoRoundYet + | BuildMeterRoundsRecorded { rounds: List, last_recorded_at: String } + | BuildMeterRoundsNotRecorded { reason: String } + +// What the worker is doing, decided by observed activity and the outcome, never by the marks. +type BuildMeterPose + = BuildMeterWorking + | BuildMeterWaitingForSeat + | BuildMeterStopped + | BuildMeterDone + | BuildMeterIdle + +fn build_meter_pose_key(pose: BuildMeterPose) -> String { + match pose { + BuildMeterWorking => "working" + BuildMeterWaitingForSeat => "waiting" + BuildMeterStopped => "stopped" + BuildMeterDone => "done" + BuildMeterIdle => "idle" + } +} + +// WHEN A CLIENT DRAWS A NEW SCENE. A stopped or published attempt has ended, so its drawing is +// drawn as soon as it is observed and is never held back. Any other scene is drawn only once it has +// held for the client's dwell (gunbc.site.build_meter_client), so activity that flaps between +// observations is never drawn as a flicker. A stale attempt's paused worker waits like any other. +type BuildMeterSettle + = BuildMeterSettleAtOnce + | BuildMeterSettleAfterDwell + +fn build_meter_settle_key(settle: BuildMeterSettle) -> String { + match settle { + BuildMeterSettleAtOnce => "at-once" + BuildMeterSettleAfterDwell => "after-dwell" + } +} + +fn build_meter_pose_settle(pose: BuildMeterPose) -> BuildMeterSettle { + match pose { + BuildMeterWorking => BuildMeterSettleAfterDwell + BuildMeterWaitingForSeat => BuildMeterSettleAfterDwell + BuildMeterStopped => BuildMeterSettleAtOnce + BuildMeterDone => BuildMeterSettleAtOnce + BuildMeterIdle => BuildMeterSettleAfterDwell + } +} + +// HOW AN ATTEMPT FAILED, as the producer classifies its failure events. A refusal is a stage that +// declined to proceed on the evidence it had; a verification failure is a candidate the +// verification stage judged; an activity failure is any other stage that ended failed; a spent +// budget is the harness stopping the agent at its round budget, which ends the stage without +// anything failing; a blocked handoff is a candidate that could not be submitted or published. +type FailureKind + = FailureRefused + | FailureVerificationFailed + | FailureActivityFailed + | FailureBudgetSpent + | FailureHandoffBlocked + +fn failure_kind_key(kind: FailureKind) -> String { + match kind { + FailureRefused => "refused" + FailureVerificationFailed => "verification-failed" + FailureActivityFailed => "failed" + FailureBudgetSpent => "budget-spent" + FailureHandoffBlocked => "handoff-blocked" + } +} + +// The words for one failure event: the stage's own label and what happened to it. The Work tab's +// failure rows and this element's accessible name both read these, so the two cannot disagree. +fn failure_event_words(kind: FailureKind, stage: WorkflowSegmentKind) -> String { + join([workflow_segment_kind_label(kind: stage), " ", match kind { + FailureRefused => "refused" + FailureVerificationFailed => "failed" + FailureActivityFailed => "failed" + FailureBudgetSpent => "stopped at its round budget" + FailureHandoffBlocked => "blocked" + }], "") +} + +// WHERE A FAILURE LINK GOES, at the precision the record has. An exact event is one recorded event +// with its own identity; a round operation is one operation in one round; a stage group is the +// failures recorded against one stage, listed together on the Work tab. The producer uses the most +// precise target its record carries and never makes one up: today the record ties a failure to its +// stage obligation and nothing finer, so every target it emits is a stage group. Each arm carries the +// Work tab anchor that opens it, and its words say how precise it is. +type FailureTarget + = FailureExactEvent { event_id: String, anchor: String } + | FailureRoundOperation { round: Int, kind: BuildMeterKind, anchor: String } + | FailureStageGroup { stage: WorkflowSegmentKind, anchor: String } + +fn failure_target_anchor(target: FailureTarget) -> String { + match target { + FailureExactEvent { event_id: _, anchor } => anchor + FailureRoundOperation { round: _, kind: _, anchor } => anchor + FailureStageGroup { stage: _, anchor } => anchor + } +} + +fn failure_target_round(target: FailureTarget) -> Int? { + match target { + FailureExactEvent { event_id: _, anchor: _ } => none + FailureRoundOperation { round, kind: _, anchor: _ } => Present { value: round } + FailureStageGroup { stage: _, anchor: _ } => none + } +} + +fn failure_target_key(target: FailureTarget) -> String { + match target { + FailureExactEvent { event_id: _, anchor: _ } => "event" + FailureRoundOperation { round: _, kind: _, anchor: _ } => "round-operation" + FailureStageGroup { stage: _, anchor: _ } => "stage-group" + } +} + +// What following the link opens, said at the target's precision. +fn failure_target_words(target: FailureTarget) -> String { + match target { + FailureExactEvent { event_id: _, anchor: _ } => "open the event" + FailureRoundOperation { round, kind, anchor: _ } => join(["open the ", build_meter_kind_key(kind: kind), " in round ", to_string(round)], "") + FailureStageGroup { stage, anchor: _ } => join(["open the ", workflow_segment_kind_label(kind: stage), " stage failures"], "") + } +} + +// Where the failure happened, at the target's precision: a round when the record ties it to one. +fn failure_target_place(target: FailureTarget) -> String { + match failure_target_round(target: target) { + Present { value: n } => join([" in round ", to_string(n)], "") + Absent => "" + } +} + +// The digest the producer derives from an attempt's failure events: the primary event, by its +// workflow obligation identity, how many failure events the attempt has (one or more by +// construction: no digest is made from none), its stage, and the target the link opens. +type FailureDigest { + kind: FailureKind + primary_event: WorkflowFactKey + event_count: Int + stage: WorkflowSegmentKind + target: FailureTarget +} + +type BuildMeterView { + attempt_key: String + attempt_label: String + stage: WorkflowSegmentKind? + stage_state: WorkflowSegmentState? + coverage: BuildMeterCoverage + pose: BuildMeterPose + stale: Bool + failure: FailureDigest? + handoff: CandidateHandoffState + destination: String +} + +// The element's input: one issue's compact work summary, its view and the revision that names the +// view. The revision is the producer's content hash of everything the element can draw or say. +type CompactWorkSummary { + subject: String + view: BuildMeterView + revision: String +} + +data build_meter_class: String = "el-build-meter" +data build_meter_body_class: String = "el-build-meter-body" +data build_meter_art_class: String = "el-build-meter-art" +data build_meter_worker_class: String = "el-build-meter-worker" +data build_meter_face_class: String = "el-build-meter-face" +data build_meter_arm_class: String = "el-build-meter-arm" +data build_meter_droop_class: String = "el-build-meter-droop" +// The hover digest wraps at a short measure rather than running past the page: it names the failure +// and the target the link opens, which can be longer than the strip. +data build_meter_digest_measure: String = "24ch" +data build_meter_sleep_class: String = "el-build-meter-sleep" +data build_meter_snooze_class: String = "el-build-meter-snooze" +data build_meter_z_class: String = "el-build-meter-z" +data build_meter_blink_class: String = "el-build-meter-blink" +data build_meter_name_class: String = "el-build-meter-name" +data build_meter_badge_class: String = "el-build-meter-badge" +data build_meter_digest_class: String = "el-build-meter-digest" + +// The parts of a drawing a change moves (build_meter_cues). The canvas holds the drawing, and two +// drawings while a change plays; the figure places the worker, the gait carries its walk and the +// worker group its bob. +data build_meter_canvas_class: String = "el-build-meter-canvas" +data build_meter_strip_class: String = "el-build-meter-strip" +data build_meter_figure_class: String = "el-build-meter-figure" +data build_meter_gait_class: String = "el-build-meter-gait" +data build_meter_finish_class: String = "el-build-meter-finish" +data build_meter_edge_class: String = "el-build-meter-edge" +data build_meter_fault_class: String = "el-build-meter-fault" + +// State classes a client may set on the element. They change decoration only. +data build_meter_drowsy_class: String = "el-build-meter-drowsy" +data build_meter_asleep_class: String = "el-build-meter-asleep" +data build_meter_waking_class: String = "el-build-meter-waking" +data build_meter_beat_class: String = "el-build-meter-beat" +// Set on the canvas while a change plays. +data build_meter_changing_class: String = "el-build-meter-changing" + +data build_meter_subject_attribute: String = "data-build-meter" +data build_meter_revision_attribute: String = "data-attempt-view-revision" +data build_meter_rounds_attribute: String = "data-meter-rounds" +data build_meter_scene_attribute: String = "data-meter-scene" +data build_meter_settle_attribute: String = "data-meter-settle" +data build_meter_stance_attribute: String = "data-meter-stance" +data build_meter_worker_x_attribute: String = "data-meter-worker-x" +data build_meter_side_attribute: String = "data-meter-side" +data build_meter_from_attribute: String = "data-meter-from" +data build_meter_to_attribute: String = "data-meter-to" +data build_meter_pose_attribute: String = "data-meter-pose" +data build_meter_coverage_attribute: String = "data-meter-coverage" +data build_meter_outcome_attribute: String = "data-meter-outcome" +data build_meter_look_attribute: String = "data-meter-look" +data build_meter_kind_attribute: String = "data-kind" +data build_meter_evidence_attribute: String = "data-evidence" +data build_meter_paint_attribute: String = "data-paint" +data build_meter_lid_attribute: String = "data-lid" +data build_meter_worker_attribute: String = "data-worker" +data build_meter_edge_attribute: String = "data-edge" +data build_meter_history_attribute: String = "data-history" +data build_meter_z_attribute: String = "data-z" +data build_meter_look_left: String = "left" +data build_meter_look_right: String = "right" + +// The member under which the workflow observation names each attempt's current view revision. +// The producer and the page's client both read the name here. +data build_meter_wire_revision_member: String = "attempt_view_revision" + +// Geometry, in the drawing's own units; one unit is one CSS pixel. +data build_meter_visible_rounds: Int = 24 +data build_meter_round_pitch: Int = 4 +data build_meter_mark_width: Int = 3 +data build_meter_mark_height: Int = 2 +data build_meter_lanes_top: Int = 3 +data build_meter_lane_count: Int = 7 +data build_meter_floor: Int = 21 +data build_meter_art_height: Int = 22 +data build_meter_worker_width: Int = 20 +data build_meter_worker_top: Int = 3 +data build_meter_unrecorded_width: Int = 31 + +fn build_meter_all_rounds(coverage: BuildMeterCoverage) -> List { + match coverage { + BuildMeterRoundsRecorded { rounds: rs, last_recorded_at: _ } => rs + BuildMeterNoRoundYet => [] + BuildMeterRoundsNotRecorded { reason: _ } => [] + } +} + +fn build_meter_coverage_key(coverage: BuildMeterCoverage) -> String { + match coverage { + BuildMeterRoundsRecorded { rounds: _, last_recorded_at: _ } => "recorded" + BuildMeterNoRoundYet => "none-yet" + BuildMeterRoundsNotRecorded { reason: _ } => "not-recorded" + } +} + +fn build_meter_round_count(view: BuildMeterView) -> Int { + count(build_meter_all_rounds(coverage: view.coverage)) +} + +// A long run draws its most recent rounds; the earlier ones are counted in the name, never +// squeezed. +fn build_meter_hidden_count(view: BuildMeterView) -> Int { + let n = build_meter_round_count(view: view) + if n > build_meter_visible_rounds { n - build_meter_visible_rounds } else { 0 } +} + +fn build_meter_drawn_rounds(view: BuildMeterView) -> List { + build_meter_all_rounds(coverage: view.coverage).skip(n: build_meter_hidden_count(view: view)) +} + +// The size of a round's base, from its recorded output tokens. It is a coarse step, not a scale +// with a top: the last step has no upper bound. +fn build_meter_token_step(output_tokens: Int) -> Int { + if output_tokens < 256 { 1 } else if output_tokens < 1024 { 2 } else if output_tokens < 4096 { 3 } else { 4 } +} + +// A seed in 0..6 from the attempt key, so that two attempts do not blink together. +fn build_meter_seed(attempt_key: String) -> Int { + let total = fold(chars(s: attempt_key), init: 0, f: (acc, c) => acc + c) + total - (total / 7) * 7 +} + +fn build_meter_is_published(handoff: CandidateHandoffState) -> Bool { + match handoff { + HandoffPublished { head_sha: _, pr_number: _, url: _ } => true + HandoffAwaitingWorker => false + HandoffYielded { reason: _ } => false + HandoffEvidenceRefused { reason: _ } => false + HandoffDeclaredBlocked { head_sha: _, readiness: _, explanation: _ } => false + HandoffSubmitted { head_sha: _ } => false + HandoffVerificationFailed { head_sha: _, verdict_key: _ } => false + HandoffAwaitingIntegration { head_sha: _ } => false + HandoffBlockedOnIntegration { head_sha: _, cause: _ } => false + HandoffPublicationOwed { head_sha: _ } => false + HandoffBlockedOnPublication { head_sha: _, cause: _ } => false + } +} + +// THE WORDS FOR A HANDOFF STATE, the one place the frontend says them. They name what the +// attempt's candidate has reached and never the issue: a published candidate is on offer for +// review, which is not the issue being done. Before a handoff there is nothing to say. +fn build_meter_handoff_words(state: CandidateHandoffState) -> String? { + match state { + HandoffAwaitingWorker => none + HandoffYielded { reason: _ } => Present { value: "Yielded without a submission" } + HandoffEvidenceRefused { reason: _ } => Present { value: "Submission evidence refused" } + HandoffDeclaredBlocked { head_sha: _, readiness, explanation: _ } => + Present { value: join(["Handed back by the worker, declared ", submission_readiness_key(r: readiness)], "") } + HandoffSubmitted { head_sha: _ } => Present { value: "Submitted, awaiting verification" } + HandoffVerificationFailed { head_sha: _, verdict_key } => Present { value: join(["Blocked on verification, verdict ", verdict_key], "") } + HandoffAwaitingIntegration { head_sha: _ } => Present { value: "Verified, awaiting integration" } + HandoffBlockedOnIntegration { head_sha: _, cause: _ } => Present { value: "Blocked on integration" } + HandoffPublicationOwed { head_sha: _ } => Present { value: "Verified, publication owed" } + HandoffBlockedOnPublication { head_sha: _, cause: _ } => Present { value: "Blocked on publication" } + HandoffPublished { head_sha: _, pr_number, url: _ } => Present { value: join(["Published for review, pull request ", to_string(pr_number)], "") } + } +} + +fn build_meter_events_words(n: Int) -> String { + if n == 1 { "One failure event" } else { join([to_string(n), " failure events"], "") } +} + +fn build_meter_rounds_words(coverage: BuildMeterCoverage) -> String { + match coverage { + BuildMeterNoRoundYet => "No round recorded yet" + BuildMeterRoundsNotRecorded { reason: _ } => "Rounds not recorded for this attempt" + BuildMeterRoundsRecorded { rounds, last_recorded_at } => + join([if count(rounds) == 1 { "1 round recorded" } else { join([to_string(count(rounds)), " rounds recorded"], "") }, ", the last at ", last_recorded_at], "") + } +} + +fn build_meter_stage_words(view: BuildMeterView) -> String { + match view.failure { + Present { value: d } => join([failure_event_words(kind: d.kind, stage: d.stage), failure_target_place(target: d.target)], "") + Absent => match view.pose { + BuildMeterWaitingForSeat => "Waiting for a serving seat" + BuildMeterWorking => build_meter_current_stage_words(view: view) + BuildMeterStopped => build_meter_current_stage_words(view: view) + BuildMeterDone => build_meter_current_stage_words(view: view) + BuildMeterIdle => build_meter_current_stage_words(view: view) + } + } +} + +fn build_meter_current_stage_words(view: BuildMeterView) -> String { + match view.stage { + Absent => "Stage unavailable" + Present { value: k } => join([workflow_segment_kind_label(kind: k), " ", match view.stage_state { + Present { value: st } => workflow_segment_state_key(state: st) + Absent => "unavailable" + }], "") + } +} + +fn build_meter_sentence(text: String) -> String { + join([text, "."], "") +} + +// THE LINK'S ACCESSIBLE NAME: every fact the drawing shows, in words, ending with where the link +// goes. It is the element's whole statement for assistive technology and is read from the view +// alone. +fn build_meter_accessible_name(view: BuildMeterView) -> String { + join(concat(concat(concat(concat( + [build_meter_sentence(text: build_meter_stage_words(view: view))], + match view.failure { Present { value: d } => [build_meter_sentence(text: build_meter_events_words(n: d.event_count))] Absent => [] }), + [build_meter_sentence(text: build_meter_rounds_words(coverage: view.coverage))]), + concat( + match build_meter_handoff_words(state: view.handoff) { Present { value: w } => [build_meter_sentence(text: w)] Absent => [] }, + if view.stale { ["Launched on an earlier revision."] } else { [] })), + [build_meter_sentence(text: match view.failure { + Present { value: d } => join(["Attempt ", view.attempt_label, ": ", failure_target_words(target: d.target)], "") + Absent => join(["Open attempt ", view.attempt_label, " work"], "") + })]), + " ") +} + +// The one optional line shown on hover or focus: the stage and the most telling count. +fn build_meter_digest_line(view: BuildMeterView) -> String { + match view.failure { + Present { value: d } => join([failure_event_words(kind: d.kind, stage: d.stage), failure_target_place(target: d.target), " · ", to_string(d.event_count), if d.event_count == 1 { " event" } else { " events" }, "; ", failure_target_words(target: d.target)], "") + Absent => join([build_meter_stage_words(view: view), " · ", match view.coverage { + BuildMeterNoRoundYet => "no round yet" + BuildMeterRoundsNotRecorded { reason: _ } => "rounds not recorded" + BuildMeterRoundsRecorded { rounds, last_recorded_at: _ } => join([to_string(count(rounds)), if count(rounds) == 1 { " round" } else { " rounds" }], "") + }], "") + } +} + +fn build_meter_svg(tag: String, attributes: List, children: List) -> Fragment { + Element { tag: tag, attributes: attributes, children: children } +} + +fn build_meter_rect(x: Int, y: Int, w: Int, h: Int, attributes: List) -> Fragment { + build_meter_svg(tag: "rect", attributes: concat(attributes, [ + attr(name: "x", value: to_string(x)), + attr(name: "y", value: to_string(y)), + attr(name: "width", value: to_string(w)), + attr(name: "height", value: to_string(h)), + ]), children: []) +} + +fn build_meter_painted(paint: String, children: List) -> Fragment { + build_meter_svg(tag: "g", attributes: [attr(name: build_meter_paint_attribute, value: paint)], children: children) +} + +fn build_meter_translate(x: Int, y: Int) -> Attribute { + attr(name: "transform", value: join(["translate(", to_string(x), " ", to_string(y), ")"], "")) +} + +fn build_meter_lane_y(kind: BuildMeterKind) -> Int { + build_meter_lanes_top + (build_meter_lane_count - 1 - build_meter_kind_lane(kind: kind)) * build_meter_mark_height +} + +// One recorded operation kind in one round: a mark in the kind's lane. A count of one is drawn +// lighter than a larger count; a write or an edit is always drawn full, lit along its top. +fn build_meter_mark_nodes(mark: BuildMeterMark) -> List { + let y = build_meter_lane_y(kind: mark.kind) + let tags = [ + attr(name: build_meter_kind_attribute, value: build_meter_kind_key(kind: mark.kind)), + attr(name: build_meter_evidence_attribute, value: build_meter_evidence_key(evidence: mark.evidence)), + ] + let lit = build_meter_kind_is_lit(kind: mark.kind) + concat( + [build_meter_rect(x: 0, y: y, w: build_meter_mark_width, h: build_meter_mark_height, + attributes: if mark.count > 1 || lit { tags } else { concat(tags, [attr(name: "opacity", value: ".6")]) })], + if lit { [build_meter_rect(x: 0, y: y, w: build_meter_mark_width, h: 1, attributes: [attr(name: build_meter_paint_attribute, value: "lit"), attr(name: "opacity", value: ".3")])] } else { [] }) +} + +// Each mark says where its count came from: a typed operation, or which legacy proxy. +fn build_meter_mark_text(mark: BuildMeterMark) -> String { + match mark.evidence { + BuildMeterTypedOperation => join([build_meter_kind_key(kind: mark.kind), " ", to_string(mark.count), " (typed)"], "") + BuildMeterToolCallCount => join([to_string(mark.count), if mark.count == 1 { " tool call" } else { " tool calls" }, " (proxy: tool-call count)"], "") + BuildMeterChangedPathCountRise => join([to_string(mark.count), if mark.count == 1 { " more changed path" } else { " more changed paths" }, " (proxy: changed-path count rise)"], "") + } +} + +fn build_meter_round_title(round: BuildMeterRound) -> String { + join([ + "Round ", to_string(round.step), ": ", + if count(round.marks) == 0 { "no operations recorded" } else { join(map(round.marks, m => build_meter_mark_text(mark: m)), ", ") }, + "; tokens ", to_string(round.input_tokens), " in, ", to_string(round.output_tokens), " out", + ], "") +} + +fn build_meter_failure_round(view: BuildMeterView) -> Int? { + match view.failure { + Present { value: d } => failure_target_round(target: d.target) + Absent => none + } +} + +// A failure the record ties to a round is drawn in that round's column: a refused operation as a +// broken mark across the lanes, a failed verification as a seal over the column. +fn build_meter_round_failure_nodes(view: BuildMeterView, round: BuildMeterRound) -> List { + match view.failure { + Absent => [] + Present { value: d } => match failure_target_round(target: d.target) { + Absent => [] + Present { value: step } => if step != round.step { [] } else { match d.kind { + FailureVerificationFailed => [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_fault_class), attr(name: build_meter_edge_attribute, value: "seal"), build_meter_translate(x: 0 - 2, y: 0)], children: [build_meter_seal_node()])] + FailureRefused => [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_fault_class), attr(name: build_meter_edge_attribute, value: "broken")], children: build_meter_broken_mark_nodes())] + FailureActivityFailed => [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_fault_class), attr(name: build_meter_edge_attribute, value: "broken")], children: build_meter_broken_mark_nodes())] + FailureBudgetSpent => [] + FailureHandoffBlocked => [] + } } + } + } +} + +fn build_meter_broken_mark_nodes() -> List { + [build_meter_painted(paint: "stop", children: [ + build_meter_rect(x: 0, y: build_meter_lanes_top, w: 1, h: 2, attributes: []), + build_meter_rect(x: 1, y: build_meter_lanes_top + 2, w: 1, h: 2, attributes: []), + build_meter_rect(x: 2, y: build_meter_lanes_top + 4, w: 1, h: 2, attributes: []), + build_meter_rect(x: 1, y: build_meter_lanes_top + 6, w: 1, h: 2, attributes: []), + build_meter_rect(x: 0, y: build_meter_lanes_top + 8, w: 1, h: 2, attributes: []), + ])] +} + +fn build_meter_round_node(view: BuildMeterView, index: Int, round: BuildMeterRound) -> Fragment { + let step = build_meter_token_step(output_tokens: round.output_tokens) + build_meter_svg(tag: "g", attributes: [ + attr(name: "data-round", value: to_string(round.step)), + build_meter_translate(x: index * build_meter_round_pitch, y: 0), + ], children: concat(concat([ + build_meter_svg(tag: "title", attributes: [], children: [txt(build_meter_round_title(round: round))]), + build_meter_rect(x: 0, y: build_meter_floor - step, w: build_meter_mark_width, h: step, attributes: [attr(name: build_meter_paint_attribute, value: "base")]), + ], flat_map(round.marks, m => build_meter_mark_nodes(mark: m))), build_meter_round_failure_nodes(view: view, round: round))) +} + +// Rounds this instrumentation did not record: a hollow, dotted frame where the history would be. +// It is drawn the same width whatever happened, so it never reads as a count, and in the record's +// own neutral paint, so it never reads as a failure. +fn build_meter_unrecorded_nodes() -> List { + let xs = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30] + let ys = [5, 7, 9, 11, 13, 15, 17] + [build_meter_painted(paint: "base", children: concat(concat( + map(xs, x => build_meter_rect(x: x, y: build_meter_lanes_top, w: 1, h: 1, attributes: [])), + map(xs, x => build_meter_rect(x: x, y: build_meter_floor - 1, w: 1, h: 1, attributes: []))), + concat( + map(ys, y => build_meter_rect(x: 0, y: y, w: 1, h: 1, attributes: [])), + map(ys, y => build_meter_rect(x: 30, y: y, w: 1, h: 1, attributes: [])))))] +} + +// No round recorded yet: three dots on the floor where the strip will start. +fn build_meter_no_round_nodes() -> List { + [build_meter_painted(paint: "base", children: [ + build_meter_rect(x: 0, y: build_meter_floor - 1, w: 1, h: 1, attributes: []), + build_meter_rect(x: 3, y: build_meter_floor - 1, w: 1, h: 1, attributes: []), + build_meter_rect(x: 6, y: build_meter_floor - 1, w: 1, h: 1, attributes: []), + ])] +} + +// A failed verification's seal: a small ring with a cross, in the error colour. +fn build_meter_seal_node() -> Fragment { + build_meter_painted(paint: "stop", children: [ + build_meter_rect(x: 2, y: 0, w: 3, h: 1, attributes: []), + build_meter_rect(x: 1, y: 1, w: 1, h: 1, attributes: []), + build_meter_rect(x: 5, y: 1, w: 1, h: 1, attributes: []), + build_meter_rect(x: 0, y: 2, w: 1, h: 3, attributes: []), + build_meter_rect(x: 6, y: 2, w: 1, h: 3, attributes: []), + build_meter_rect(x: 1, y: 5, w: 1, h: 1, attributes: []), + build_meter_rect(x: 5, y: 5, w: 1, h: 1, attributes: []), + build_meter_rect(x: 2, y: 6, w: 3, h: 1, attributes: []), + build_meter_rect(x: 2, y: 2, w: 1, h: 1, attributes: []), + build_meter_rect(x: 4, y: 2, w: 1, h: 1, attributes: []), + build_meter_rect(x: 3, y: 3, w: 1, h: 1, attributes: []), + build_meter_rect(x: 2, y: 4, w: 1, h: 1, attributes: []), + build_meter_rect(x: 4, y: 4, w: 1, h: 1, attributes: []), + ]) +} + +// A stage that failed with no round to tie it to: a stop cap at the working edge. +fn build_meter_stop_cap_node() -> Fragment { + build_meter_painted(paint: "stop", children: [ + build_meter_rect(x: 0, y: 2, w: 5, h: 2, attributes: []), + build_meter_rect(x: 2, y: 4, w: 1, h: build_meter_floor - 4, attributes: []), + ]) +} + +// The harness stopping the agent at its budget is not a failure: a closing bracket in the record's +// own neutral paint. +fn build_meter_budget_bracket_node() -> Fragment { + build_meter_painted(paint: "prop", children: [ + build_meter_rect(x: 0, y: 2, w: 3, h: 1, attributes: []), + build_meter_rect(x: 2, y: 2, w: 1, h: build_meter_floor - 2, attributes: []), + build_meter_rect(x: 0, y: build_meter_floor - 1, w: 3, h: 1, attributes: []), + ]) +} + +// The mark at the working edge for a failure the record does not tie to a round; none when it +// does (the column carries it) or when there is no failure. +fn build_meter_edge_mark(view: BuildMeterView) -> Fragment? { + match view.failure { + Absent => none + Present { value: d } => match failure_target_round(target: d.target) { + Present { value: _ } => none + Absent => Present { value: match d.kind { + FailureVerificationFailed => build_meter_edge_group(edge: "seal", y: 7, mark: build_meter_seal_node()) + FailureRefused => build_meter_edge_group(edge: "stop-cap", y: 0, mark: build_meter_stop_cap_node()) + FailureActivityFailed => build_meter_edge_group(edge: "stop-cap", y: 0, mark: build_meter_stop_cap_node()) + FailureHandoffBlocked => build_meter_edge_group(edge: "stop-cap", y: 0, mark: build_meter_stop_cap_node()) + FailureBudgetSpent => build_meter_edge_group(edge: "budget", y: 0, mark: build_meter_budget_bracket_node()) + } } + } + } +} + +fn build_meter_edge_group(edge: String, y: Int, mark: Fragment) -> Fragment { + build_meter_svg(tag: "g", attributes: [attr(name: build_meter_edge_attribute, value: edge), build_meter_translate(x: 0, y: y)], children: [mark]) +} + +fn build_meter_edge_mark_width(view: BuildMeterView) -> Int { + match build_meter_edge_mark(view: view) { + Absent => 0 + Present { value: _ } => 10 + } +} + +// The worker is the Fabric workflow's mark (gunbc.site.robot_mark), the gunbai robot variant, at +// strip size: the squircle body, two square eyes, a flat mouth and a ball-topped antenna. Every +// position is read from the mark's own numbers and scaled so the body is the worker's width; one +// unit of the drawing is one CSS pixel, so each value is rounded to the nearest unit. Three +// quantisations are deliberate and named here. The right eye is the left eye mirrored, as the mark +// is symmetric, so rounding cannot set the eyes off centre. The mouth is widened to an even width +// so it centres on the even body. The antenna, two units tall at this scale, is drawn five units +// tall (a one-unit stem under a ball six units across with its corners cut) so the ball reads as a +// ball above its stem; the narrower balls tried before read as a cross. The feet are the worker's own: the mark has none. +type BuildMeterBox { + x: Int + y: Int + w: Int + h: Int +} + +fn build_meter_box(x: Int, y: Int, w: Int, h: Int) -> BuildMeterBox { + BuildMeterBox { x: x, y: y, w: w, h: h } +} + +// The mark's tenths of a unit across the body, after the body group's scale. +fn build_meter_mark_span() -> Int { + robot_mark_body.w * robot_mark_body_scale_tenths +} + +// A length of the mark, in tenths of its units after the body's scale, as the nearest whole unit +// of the worker. +fn build_meter_sprite_units(tenths: Int) -> Int { + (tenths * build_meter_worker_width * 2 + build_meter_mark_span()) / (2 * build_meter_mark_span()) +} + +fn build_meter_mark_length(v: Int) -> Int { + build_meter_sprite_units(tenths: v * robot_mark_body_scale_tenths) +} + +data build_meter_body_height: Int = build_meter_mark_length(v: robot_mark_body.h) +data build_meter_corner_radius: Int = build_meter_mark_length(v: robot_mark_body.r) +data build_meter_eye_size: Int = build_meter_mark_length(v: robot_mark_left_eye.w) +data build_meter_eye_left: Int = build_meter_mark_length(v: robot_mark_left_eye.x - robot_mark_body.x) +data build_meter_eye_top: Int = build_meter_mark_length(v: robot_mark_left_eye.y - robot_mark_body.y) +data build_meter_eye_lefts: List = [build_meter_eye_left, build_meter_worker_width - build_meter_eye_left - build_meter_eye_size] +data build_meter_mouth_top: Int = build_meter_mark_length(v: robot_mark_mouth.y - robot_mark_body.y) + +fn build_meter_even_width(w: Int) -> Int { + if (build_meter_worker_width - w) % 2 == 0 { w } else { w + 1 } +} + +data build_meter_mouth_width: Int = build_meter_even_width(w: build_meter_mark_length(v: robot_mark_mouth.w)) +data build_meter_mouth: BuildMeterBox = build_meter_box(x: (build_meter_worker_width - build_meter_mouth_width) / 2, y: build_meter_mouth_top, w: build_meter_mouth_width, h: 1) + +// The squircle's corner, pixel by pixel: on each row within the corner radius of the top or the +// bottom, the first column whose pixel centre lies inside the corner's circle. +fn build_meter_inset_search(r: Int, dy: Int, i: Int, budget: Int) -> Int { + if budget <= 0 || (2 * r - 2 * i - 1) * (2 * r - 2 * i - 1) + dy * dy <= 4 * r * r { + i + } else { + build_meter_inset_search(r: r, dy: dy, i: i + 1, budget: budget - 1) + } +} + +fn build_meter_body_inset(y: Int) -> Int { + let from_edge = if y < build_meter_body_height - 1 - y { y } else { build_meter_body_height - 1 - y } + if y < 0 || y >= build_meter_body_height { + build_meter_worker_width + } else if from_edge >= build_meter_corner_radius { + 0 + } else { + build_meter_inset_search(r: build_meter_corner_radius, dy: 2 * build_meter_corner_radius - 2 * from_edge - 1, i: 0, budget: build_meter_corner_radius) + } +} + +fn build_meter_body_ys(y: Int) -> List { + if y >= build_meter_body_height { [] } else { concat([y], build_meter_body_ys(y: y + 1)) } +} + +fn build_meter_body_row(y: Int) -> BuildMeterBox { + let i = build_meter_body_inset(y: y) + build_meter_box(x: i, y: y, w: build_meter_worker_width - 2 * i, h: 1) +} + +data build_meter_body_rows: List = map(build_meter_body_ys(y: 0), y => build_meter_body_row(y: y)) + +// The antenna above the body's top row: a two-unit stem centred on the body, under a ball. +data build_meter_antenna_stem_rows: List = [ + build_meter_box(x: build_meter_worker_width / 2 - 1, y: 0 - 1, w: 2, h: 1), +] +// The ball is six units across and four tall with its corners cut, wider than its two-unit +// stalk, so it reads as a round ball; any ball four units across is a plus sign at this size. +data build_meter_antenna_ball_rows: List = [ + build_meter_box(x: build_meter_worker_width / 2 - 2, y: 0 - 2, w: 4, h: 1), + build_meter_box(x: build_meter_worker_width / 2 - 3, y: 0 - 4, w: 6, h: 2), + build_meter_box(x: build_meter_worker_width / 2 - 2, y: 0 - 5, w: 4, h: 1), +] +data build_meter_antenna_rows: List = concat(build_meter_antenna_stem_rows, build_meter_antenna_ball_rows) + +// The outline of the same body, for a paused (stale) worker: each row's edge pixels (the whole row +// at the top and bottom, and on the corners as far as the next row steps in), hollow feet, the +// antenna, and the eyes and mouth as flat strokes on the eyes' lowest row and the mouth's row. +fn build_meter_edge_width(y: Int) -> Int { + let i = build_meter_body_inset(y: y) + let up = build_meter_body_inset(y: y - 1) - i + let down = build_meter_body_inset(y: y + 1) - i + let step = if up > down { up } else { down } + if step < 1 { 1 } else { step } +} + +fn build_meter_edge_row(y: Int) -> List { + let i = build_meter_body_inset(y: y) + if y == 0 || y == build_meter_body_height - 1 { + [build_meter_box(x: i, y: y, w: build_meter_worker_width - 2 * i, h: 1)] + } else { + let w = build_meter_edge_width(y: y) + [build_meter_box(x: i, y: y, w: w, h: 1), build_meter_box(x: build_meter_worker_width - i - w, y: y, w: w, h: 1)] + } +} + +data build_meter_edge_rows: List = concat(concat(flat_map(build_meter_body_ys(y: 0), y => build_meter_edge_row(y: y)), build_meter_antenna_rows), concat([ + build_meter_box(x: 3, y: 16, w: 1, h: 2), build_meter_box(x: 6, y: 16, w: 1, h: 2), build_meter_box(x: 4, y: 17, w: 2, h: 1), + build_meter_box(x: 13, y: 16, w: 1, h: 2), build_meter_box(x: 16, y: 16, w: 1, h: 2), build_meter_box(x: 14, y: 17, w: 2, h: 1), + build_meter_mouth, +], map(build_meter_eye_lefts, x => build_meter_box(x: x, y: build_meter_eye_top + build_meter_eye_size - 1, w: build_meter_eye_size, h: 1)))) +data build_meter_light_rows: List = [build_meter_box(x: 5, y: 1, w: 5, h: 1), build_meter_box(x: 3, y: 2, w: 2, h: 1), build_meter_box(x: 2, y: 3, w: 1, h: 2)] +data build_meter_shade_rows: List = [build_meter_box(x: 2, y: 14, w: 16, h: 1), build_meter_box(x: 4, y: 15, w: 12, h: 1), build_meter_box(x: 19, y: 6, w: 1, h: 6)] +data build_meter_feet_rows: List = [build_meter_box(x: 3, y: 16, w: 4, h: 2), build_meter_box(x: 13, y: 16, w: 4, h: 2)] +// Square eyes close from the top: the drooping lid covers the eyes' top row (a shorter bar), the +// sleeping lid the next (a flat line on the lowest row), and a blink both. +data build_meter_upper_lid_rows: List = [build_meter_eye_top] +data build_meter_lower_lid_rows: List = [build_meter_eye_top + 1] + +fn build_meter_rows(rows: List) -> List { + map(rows, r => build_meter_rect(x: r.x, y: r.y, w: r.w, h: r.h, attributes: [])) +} + +fn build_meter_lid_rects(rows: List) -> List { + flat_map(build_meter_eye_lefts, x => map(rows, y => build_meter_rect(x: x, y: y, w: build_meter_eye_size, h: 1, attributes: []))) +} + +// A lid group is drawn over the eyes in the lid colour. `on` is the pose's own still; a group +// that is off is there for decoration to raise. +fn build_meter_lid_group(class_name: String, rows: List, on: Bool) -> Fragment { + build_meter_svg(tag: "g", attributes: [ + attr(name: "class", value: class_name), + attr(name: build_meter_paint_attribute, value: "lid"), + attr(name: build_meter_lid_attribute, value: if on { "on" } else { "off" }), + ], children: build_meter_lid_rects(rows: rows)) +} + +fn build_meter_pose_droops(pose: BuildMeterPose) -> Bool { + match pose { + BuildMeterWorking => false + BuildMeterWaitingForSeat => true + BuildMeterStopped => true + BuildMeterDone => false + BuildMeterIdle => true + } +} + + +// Where the worker rests. On its feet it works, idles, stands stopped, or stands beside a +// published attempt's finish rule. Waiting for a serving seat it sits on the floor, two units +// lower, with no feet and its eyes shut, and naps. +type BuildMeterRest + = BuildMeterOnFeet + | BuildMeterOnFloor + +fn build_meter_pose_rest(pose: BuildMeterPose) -> BuildMeterRest { + match pose { + BuildMeterWorking => BuildMeterOnFeet + BuildMeterWaitingForSeat => BuildMeterOnFloor + BuildMeterStopped => BuildMeterOnFeet + BuildMeterDone => BuildMeterOnFeet + BuildMeterIdle => BuildMeterOnFeet + } +} + +fn build_meter_rest_sits(rest: BuildMeterRest) -> Bool { + match rest { + BuildMeterOnFeet => false + BuildMeterOnFloor => true + } +} + +fn build_meter_rest_drop(rest: BuildMeterRest) -> Int { + match rest { + BuildMeterOnFeet => 0 + BuildMeterOnFloor => 2 + } +} + +fn build_meter_pose_reaches(pose: BuildMeterPose) -> Bool { + match pose { + BuildMeterWorking => true + BuildMeterWaitingForSeat => false + BuildMeterStopped => false + BuildMeterDone => false + BuildMeterIdle => false + } +} + +fn build_meter_eye_node(x: Int) -> Fragment { + build_meter_rect(x: x, y: build_meter_eye_top, w: build_meter_eye_size, h: build_meter_eye_size, attributes: []) +} + +// Standing: feet under the body. Sitting: no feet and eyes shut, on the floor +// (build_meter_pose_rest). The face follows the outcome (build_meter_expression). +fn build_meter_worker_node(pose: BuildMeterPose, expression: BuildMeterExpression, stale: Bool, x: Int) -> Fragment { + if stale { build_meter_paused_worker_node(x: x) } else { build_meter_solid_worker_node(pose: pose, expression: expression, x: x) } +} + +// HOW THE WORKER FEELS ABOUT THE OUTCOME, from the outcome alone. Deadpan is the character, and it +// stays deadpan while it works, waits, idles, or stops at its step budget (which is not a failure). +// A published attempt accomplished something, so its worker is mildly content: its eyes close in +// two small arcs over a small smile, and its antenna lights green. An attempt that failed +// leaves it mildly sad: its lids droop over a small frown, and its antenna lights red. +type BuildMeterExpression + = BuildMeterDeadpan + | BuildMeterContent + | BuildMeterSad + +fn build_meter_expression(view: BuildMeterView) -> BuildMeterExpression { + match view.failure { + Present { value: d } => match d.kind { + FailureBudgetSpent => BuildMeterDeadpan + FailureVerificationFailed => BuildMeterSad + FailureRefused => BuildMeterSad + FailureActivityFailed => BuildMeterSad + FailureHandoffBlocked => BuildMeterSad + } + Absent => if build_meter_is_published(handoff: view.handoff) { BuildMeterContent } else { BuildMeterDeadpan } + } +} + +fn build_meter_px(x: Int, y: Int) -> BuildMeterBox { + build_meter_box(x: x, y: y, w: 1, h: 1) +} + +// A content eye: the square's top middle and its two lower corners, an arc closed upward. +fn build_meter_arc_eye_rows(x: Int) -> List { + [build_meter_px(x: x + build_meter_eye_size / 2, y: build_meter_eye_top), build_meter_px(x: x, y: build_meter_eye_top + 1), build_meter_px(x: x + build_meter_eye_size - 1, y: build_meter_eye_top + 1)] +} + +// The mouth bent one unit at its ends: up for a smile, down for a frown, its middle on the mouth's row. +fn build_meter_bent_mouth_rows(up: Bool) -> List { + let corner_y = if up { build_meter_mouth.y - 1 } else { build_meter_mouth.y + 1 } + [ + build_meter_px(x: build_meter_mouth.x, y: corner_y), + build_meter_box(x: build_meter_mouth.x + 1, y: build_meter_mouth.y, w: build_meter_mouth.w - 2, h: 1), + build_meter_px(x: build_meter_mouth.x + build_meter_mouth.w - 1, y: corner_y), + ] +} + +fn build_meter_face_nodes(pose: BuildMeterPose, expression: BuildMeterExpression, sits: Bool) -> List { + match expression { + BuildMeterContent => [build_meter_painted(paint: "feature", children: build_meter_rows(rows: concat(flat_map(build_meter_eye_lefts, x => build_meter_arc_eye_rows(x: x)), build_meter_bent_mouth_rows(up: true))))] + BuildMeterSad => [ + build_meter_painted(paint: "feature", children: concat(map(build_meter_eye_lefts, x => build_meter_eye_node(x: x)), build_meter_rows(rows: build_meter_bent_mouth_rows(up: false)))), + build_meter_lid_group(class_name: build_meter_droop_class, rows: build_meter_upper_lid_rows, on: true), + build_meter_lid_group(class_name: build_meter_sleep_class, rows: build_meter_lower_lid_rows, on: false), + build_meter_lid_group(class_name: build_meter_blink_class, rows: concat(build_meter_upper_lid_rows, build_meter_lower_lid_rows), on: false), + ] + BuildMeterDeadpan => [ + build_meter_painted(paint: "feature", children: concat(map(build_meter_eye_lefts, x => build_meter_eye_node(x: x)), build_meter_rows(rows: [build_meter_mouth]))), + build_meter_lid_group(class_name: build_meter_droop_class, rows: build_meter_upper_lid_rows, on: build_meter_pose_droops(pose: pose)), + build_meter_lid_group(class_name: build_meter_sleep_class, rows: build_meter_lower_lid_rows, on: sits), + build_meter_lid_group(class_name: build_meter_blink_class, rows: concat(build_meter_upper_lid_rows, build_meter_lower_lid_rows), on: false), + ] + } +} + +// The antenna ball lights with the outcome: green for a published attempt, the error red for a +// failed one, and the body's own paint otherwise. +fn build_meter_antenna_paint(expression: BuildMeterExpression) -> String { + match expression { + BuildMeterContent => "success" + BuildMeterSad => "stop" + BuildMeterDeadpan => "body" + } +} + +// Where the worker stands, as three nested groups: the figure at its place, the gait a change +// walks, and the worker itself. A CSS transform replaces an SVG transform attribute on the same +// element, so each motion has a group of its own. +fn build_meter_figure_node(x: Int, y: Int, worker: Fragment) -> Fragment { + build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_figure_class), build_meter_translate(x: x, y: y)], children: [ + build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_gait_class)], children: [worker]), + ]) +} + +// Paused: the attempt was launched on a revision the deployed tree has moved past, so its worker +// stands as an outline. It neither works nor sleeps, and it carries no colour of its own. +fn build_meter_paused_worker_node(x: Int) -> Fragment { + build_meter_figure_node(x: x, y: build_meter_worker_top, worker: build_meter_svg(tag: "g", attributes: [ + attr(name: "class", value: build_meter_worker_class), + attr(name: build_meter_worker_attribute, value: "outline"), + ], children: [build_meter_painted(paint: "edge", children: build_meter_rows(rows: build_meter_edge_rows))])) +} + +fn build_meter_solid_worker_node(pose: BuildMeterPose, expression: BuildMeterExpression, x: Int) -> Fragment { + let rest = build_meter_pose_rest(pose: pose) + let sits = build_meter_rest_sits(rest: rest) + let reaches = build_meter_pose_reaches(pose: pose) + build_meter_figure_node(x: x, y: build_meter_worker_top + build_meter_rest_drop(rest: rest), worker: build_meter_svg(tag: "g", attributes: [ + attr(name: "class", value: build_meter_worker_class), + attr(name: build_meter_worker_attribute, value: "solid"), + ], children: concat( + concat( + if sits { [] } else { [build_meter_painted(paint: "shade", children: build_meter_rows(rows: build_meter_feet_rows))] }, + if reaches { + [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_arm_class), attr(name: build_meter_paint_attribute, value: "body")], children: [build_meter_rect(x: 0 - 2, y: 8, w: 3, h: 3, attributes: [])])] + } else { [] }), + [ + build_meter_painted(paint: "body", children: build_meter_rows(rows: concat(build_meter_body_rows, build_meter_antenna_stem_rows))), + build_meter_painted(paint: build_meter_antenna_paint(expression: expression), children: build_meter_rows(rows: build_meter_antenna_ball_rows)), + build_meter_painted(paint: "light", children: build_meter_rows(rows: build_meter_light_rows)), + build_meter_painted(paint: "shade", children: build_meter_rows(rows: build_meter_shade_rows)), + build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_face_class)], children: build_meter_face_nodes(pose: pose, expression: expression, sits: sits)), + ]))) +} + +// THE NAP: two z glyphs beside a sitting worker's head, the small one (three units wide, so its +// diagonal needs four rows to read as a z rather than an I) lower and nearer, in the prop paint. It +// is the one drawing of sleep, the worker waiting for a serving seat on the floor, and +// placed from the worker's own top. At rest both glyphs show; decoration +// drifts each up and away in turn (build_meter_drift_cycle), seeded per attempt like the blink and +// removed under reduced motion by the shared rule, which leaves both showing. The glyph that moves +// is an inner group, because a CSS transform replaces an SVG transform attribute on the same +// element. +data build_meter_snooze_width: Int = 9 +data build_meter_z_small_rows: List = [ + build_meter_box(x: 0, y: 0, w: 3, h: 1), + build_meter_box(x: 1, y: 1, w: 1, h: 1), + build_meter_box(x: 0, y: 2, w: 1, h: 1), + build_meter_box(x: 0, y: 3, w: 3, h: 1), +] +data build_meter_z_large_rows: List = [ + build_meter_box(x: 0, y: 0, w: 4, h: 1), + build_meter_box(x: 2, y: 1, w: 1, h: 1), + build_meter_box(x: 1, y: 2, w: 1, h: 1), + build_meter_box(x: 0, y: 3, w: 4, h: 1), +] + +fn build_meter_z_node(index: Int, x: Int, y: Int, rows: List) -> Fragment { + build_meter_svg(tag: "g", attributes: [build_meter_translate(x: x, y: y)], children: [ + build_meter_svg(tag: "g", attributes: [ + attr(name: "class", value: build_meter_z_class), + attr(name: build_meter_z_attribute, value: to_string(index)), + attr(name: build_meter_paint_attribute, value: "prop"), + ], children: build_meter_rows(rows: rows)), + ]) +} + +// The nap for a worker drawn at worker_x whose body's top row is at top. +fn build_meter_snooze_node(worker_x: Int, top: Int) -> Fragment { + build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_snooze_class)], children: [ + build_meter_z_node(index: 1, x: worker_x + build_meter_worker_width + 1, y: top + 4, rows: build_meter_z_small_rows), + build_meter_z_node(index: 2, x: worker_x + build_meter_worker_width + 4, y: top - 2, rows: build_meter_z_large_rows), + ]) +} + +fn build_meter_history_width(view: BuildMeterView) -> Int { + match view.coverage { + BuildMeterRoundsRecorded { rounds: _, last_recorded_at: _ } => count(build_meter_drawn_rounds(view: view)) * build_meter_round_pitch - 1 + BuildMeterNoRoundYet => 7 + BuildMeterRoundsNotRecorded { reason: _ } => build_meter_unrecorded_width + } +} + +fn build_meter_history_nodes(view: BuildMeterView) -> List { + match view.coverage { + BuildMeterRoundsRecorded { rounds: _, last_recorded_at: _ } => + map(enumerate(build_meter_drawn_rounds(view: view)), pair => build_meter_round_node(view: view, index: pair.first, round: pair.second)) + BuildMeterNoRoundYet => [build_meter_svg(tag: "g", attributes: [attr(name: build_meter_history_attribute, value: "none-yet")], children: build_meter_no_round_nodes())] + BuildMeterRoundsNotRecorded { reason: _ } => [build_meter_svg(tag: "g", attributes: [attr(name: build_meter_history_attribute, value: "not-recorded")], children: build_meter_unrecorded_nodes())] + } +} + +// A paused (stale) worker neither waits nor sleeps. +fn build_meter_naps(view: BuildMeterView) -> Bool { + !view.stale && build_meter_rest_sits(rest: build_meter_pose_rest(pose: view.pose)) +} + +// WHAT THE DRAWING SHOWS THE WORKER DOING: its pose, or paused for a stale attempt, whose worker is +// the outline whatever the pose. A client compares scenes to tell a new drawing from a grown one, +// and reads from the scene when to draw it and how the worker rests. +type BuildMeterScene + = BuildMeterPoseScene { pose: BuildMeterPose } + | BuildMeterPausedScene + +type BuildMeterStance + = BuildMeterStanding + | BuildMeterSitting + | BuildMeterOutline + +fn build_meter_scene(view: BuildMeterView) -> BuildMeterScene { + if view.stale { BuildMeterPausedScene } else { BuildMeterPoseScene { pose: view.pose } } +} + +fn build_meter_scene_key(scene: BuildMeterScene) -> String { + match scene { + BuildMeterPoseScene { pose } => build_meter_pose_key(pose: pose) + BuildMeterPausedScene => "paused" + } +} + +fn build_meter_scene_settle(scene: BuildMeterScene) -> BuildMeterSettle { + match scene { + BuildMeterPoseScene { pose } => build_meter_pose_settle(pose: pose) + BuildMeterPausedScene => BuildMeterSettleAfterDwell + } +} + +fn build_meter_scene_stance(scene: BuildMeterScene) -> BuildMeterStance { + match scene { + BuildMeterPoseScene { pose } => if build_meter_rest_sits(rest: build_meter_pose_rest(pose: pose)) { BuildMeterSitting } else { BuildMeterStanding } + BuildMeterPausedScene => BuildMeterOutline + } +} + +fn build_meter_stance_key(stance: BuildMeterStance) -> String { + match stance { + BuildMeterStanding => "standing" + BuildMeterSitting => "sitting" + BuildMeterOutline => "outline" + } +} + +fn build_meter_art(view: BuildMeterView) -> Fragment { + let history = build_meter_history_width(view: view) + let edge_x = history + 3 + let naps = build_meter_naps(view: view) + let worker_x = edge_x + build_meter_edge_mark_width(view: view) + (if build_meter_pose_reaches(pose: view.pose) && !view.stale { 2 } else { 0 }) + let worker_top = build_meter_worker_top + build_meter_rest_drop(rest: build_meter_pose_rest(pose: view.pose)) + let width = worker_x + build_meter_worker_width + (if naps { build_meter_snooze_width } else { 1 }) + let finish = if build_meter_is_published(handoff: view.handoff) && history > 0 { + [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_finish_class)], children: [ + build_meter_rect(x: 0, y: build_meter_floor, w: history, h: 1, attributes: [attr(name: build_meter_paint_attribute, value: "finish")]), + ])] + } else { [] } + let edge = match build_meter_edge_mark(view: view) { + Present { value: mark } => [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_edge_class), build_meter_translate(x: edge_x, y: 0)], children: [mark])] + Absent => [] + } + let scene = build_meter_scene(view: view) + build_meter_svg(tag: "svg", attributes: [ + attr(name: "class", value: build_meter_art_class), + attr(name: "aria-hidden", value: "true"), + attr(name: "focusable", value: "false"), + attr(name: "width", value: to_string(width)), + attr(name: "height", value: to_string(build_meter_art_height)), + attr(name: "viewBox", value: join(["0 0 ", to_string(width), " ", to_string(build_meter_art_height)], "")), + attr(name: "shape-rendering", value: "crispEdges"), + attr(name: build_meter_scene_attribute, value: build_meter_scene_key(scene: scene)), + attr(name: build_meter_settle_attribute, value: build_meter_settle_key(settle: build_meter_scene_settle(scene: scene))), + attr(name: build_meter_stance_attribute, value: build_meter_stance_key(stance: build_meter_scene_stance(scene: scene))), + attr(name: build_meter_worker_x_attribute, value: to_string(worker_x)), + attr(name: build_meter_rounds_attribute, value: to_string(build_meter_round_count(view: view))), + ], children: concat( + [build_meter_svg(tag: "g", attributes: [attr(name: "class", value: build_meter_strip_class)], children: build_meter_history_nodes(view: view))], + concat(finish, concat(edge, concat( + [build_meter_worker_node(pose: view.pose, expression: build_meter_expression(view: view), stale: view.stale, x: worker_x)], + if naps { [build_meter_snooze_node(worker_x: worker_x, top: worker_top)] } else { [] }))))) +} + +fn build_meter_outcome_key(view: BuildMeterView) -> String { + match view.failure { + Present { value: d } => failure_kind_key(kind: d.kind) + Absent => if build_meter_is_published(handoff: view.handoff) { "published" } else { "open" } + } +} + +data build_meter_blink_period_property: String = "meter-blink-period" +data build_meter_blink_delay_property: String = "meter-blink-delay" +data build_meter_drift_period_property: String = "meter-drift-period" +data build_meter_drift_delay_property: String = "meter-drift-delay" + +fn build_meter_seed_style(attempt_key: String) -> String { + let seed = build_meter_seed(attempt_key: attempt_key) + join([ + "--", build_meter_blink_period_property, ": ", to_string(6100 + seed * 470), "ms; ", + "--", build_meter_blink_delay_property, ": -", to_string(seed * 830), "ms; ", + "--", build_meter_drift_period_property, ": ", to_string(build_meter_drift_timing.duration_ms + seed * 390), "ms; ", + "--", build_meter_drift_delay_property, ": -", to_string(seed * 610), "ms", + ], "") +} + +// The body is everything that changes with the summary. A client that receives a newer body takes +// its facts at once (its attributes, the badge, the accessible name, the digest and the link +// destination) and draws its drawing, held in the canvas, when the drawing's scene settles +// (gunbc.site.build_meter_client). +fn build_meter_body(summary: CompactWorkSummary) -> Fragment { + let view = summary.view + el_attrs("span", [ + attr(name: "class", value: build_meter_body_class), + attr(name: build_meter_revision_attribute, value: summary.revision), + attr(name: build_meter_pose_attribute, value: build_meter_pose_key(pose: view.pose)), + attr(name: build_meter_coverage_attribute, value: build_meter_coverage_key(coverage: view.coverage)), + attr(name: build_meter_outcome_attribute, value: build_meter_outcome_key(view: view)), + attr(name: "style", value: build_meter_seed_style(attempt_key: view.attempt_key)), + ], concat(concat( + [el_attrs("span", [attr(name: "class", value: build_meter_canvas_class)], [build_meter_art(view: view)])], + match view.failure { + Present { value: d } => if d.event_count > 1 { + [el_attrs("span", [attr(name: "class", value: build_meter_badge_class), attr(name: "aria-hidden", value: "true")], [txt(join(["×", to_string(d.event_count)], ""))])] + } else { [] } + Absent => [] + }), + [ + el_attrs("span", [attr(name: "class", value: build_meter_name_class)], [txt(build_meter_accessible_name(view: view))]), + el_attrs("span", [attr(name: "class", value: build_meter_digest_class), attr(name: "aria-hidden", value: "true")], [txt(build_meter_digest_line(view: view))]), + ])) +} + +// The element: one link whose destination is the view's, carrying the subject so a client can +// find it, and whatever placement attributes the page gives it. +fn build_meter(summary: CompactWorkSummary, link_attributes: List) -> Fragment { + el_attrs("a", concat([ + attr(name: "class", value: build_meter_class), + attr(name: "href", value: summary.view.destination), + attr(name: build_meter_subject_attribute, value: summary.subject), + ], link_attributes), [build_meter_body(summary: summary)]) +} + +// Timing of the decoration. These are the element's own: they are long idle periods and one +// acted beat, not interface responses, so they are not rows of the shared response scale. +data build_meter_blink_timing: TimingToken = TimingToken { name: "meter-blink", duration_ms: 6100, easing: "step-end" } +data build_meter_tap_timing: TimingToken = TimingToken { name: "meter-tap", duration_ms: 2200, easing: "step-end" } +data build_meter_beat_timing: TimingToken = TimingToken { name: "meter-beat", duration_ms: 1400, easing: "step-end" } +data build_meter_doze_timing: TimingToken = TimingToken { name: "meter-doze", duration_ms: 900, easing: "ease" } +data build_meter_drift_timing: TimingToken = TimingToken { name: "meter-drift", duration_ms: 4800, easing: "step-end" } + +fn build_meter_opacity_frame(offset_percent: Int, value: String) -> Keyframe { + Keyframe { offset_percent: offset_percent, decls: [decl(prop: Opacity, value: value)] } +} + +fn build_meter_shift(x: String, y: String) -> String { + join(["translate(", x, ", ", y, ")"], "") +} + +fn build_meter_negative(length: String) -> String { + join(["calc(", length, " * -1)"], "") +} + +data build_meter_blink_cycle: MotionCycle = MotionCycle { name: "meter-blink", frames: [ + build_meter_opacity_frame(offset_percent: 0, value: "0"), + build_meter_opacity_frame(offset_percent: 96, value: "1"), + build_meter_opacity_frame(offset_percent: 100, value: "0"), +] } + +data build_meter_tap_cycle: MotionCycle = MotionCycle { name: "meter-tap", frames: [ + Keyframe { offset_percent: 0, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, + Keyframe { offset_percent: 50, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: build_meter_negative(length: scale_var(t: space_2))))] }, + Keyframe { offset_percent: 100, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, +] } + +// One beat when a round lands: a nudge toward the strip, then a long blink. Nothing else. +data build_meter_nudge_cycle: MotionCycle = MotionCycle { name: "meter-nudge", frames: [ + Keyframe { offset_percent: 0, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, + Keyframe { offset_percent: 8, decls: [decl(prop: Transform, value: build_meter_shift(x: build_meter_negative(length: scale_var(t: space_1)), y: "0"))] }, + Keyframe { offset_percent: 24, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, + Keyframe { offset_percent: 100, decls: [decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, +] } + +// A nap's z drifting: it appears where it rests, rises one unit, slides one unit away from the +// worker while it fades, and is gone a moment before the cycle starts again. Each step is a whole +// unit (step-end), as everything in the drawing is, and the rest position is the drawing's own, +// so stilling the cycle leaves the z showing where it rests. +data build_meter_drift_cycle: MotionCycle = MotionCycle { name: "meter-drift", frames: [ + Keyframe { offset_percent: 0, decls: [decl(prop: Opacity, value: "0"), decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, + Keyframe { offset_percent: 12, decls: [decl(prop: Opacity, value: "1"), decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, + Keyframe { offset_percent: 50, decls: [decl(prop: Opacity, value: "1"), decl(prop: Transform, value: build_meter_shift(x: "0", y: build_meter_negative(length: scale_var(t: space_1))))] }, + Keyframe { offset_percent: 75, decls: [decl(prop: Opacity, value: ".5"), decl(prop: Transform, value: build_meter_shift(x: scale_var(t: space_1), y: build_meter_negative(length: scale_var(t: space_1))))] }, + Keyframe { offset_percent: 88, decls: [decl(prop: Opacity, value: "0"), decl(prop: Transform, value: build_meter_shift(x: scale_var(t: space_1), y: build_meter_negative(length: scale_var(t: space_1))))] }, + Keyframe { offset_percent: 100, decls: [decl(prop: Opacity, value: "0"), decl(prop: Transform, value: build_meter_shift(x: "0", y: "0"))] }, +] } + +data build_meter_long_blink_cycle: MotionCycle = MotionCycle { name: "meter-long-blink", frames: [ + build_meter_opacity_frame(offset_percent: 0, value: "0"), + build_meter_opacity_frame(offset_percent: 36, value: "1"), + build_meter_opacity_frame(offset_percent: 84, value: "0"), + build_meter_opacity_frame(offset_percent: 100, value: "0"), +] } + +// A CHANGE OF SCENE, AS ROWS. A client that draws a new scene puts the arriving drawing in the +// canvas under the drawn one, marks each drawing with its side, copies both stances onto the canvas, +// sets the walk (the arriving worker's x less the leaving worker's, in units) and the two widths, +// and keeps the changing class on the canvas for build_meter_change_ms before it removes the +// leaving drawing. These rows are everything that moves meanwhile. The record does not animate: the +// leaving strip is hidden and the arriving one drawn from the start, so only the scene changes. +type BuildMeterSide + = BuildMeterLeaving + | BuildMeterArriving + +fn build_meter_side_key(side: BuildMeterSide) -> String { + match side { + BuildMeterLeaving => "leaving" + BuildMeterArriving => "arriving" + } +} + +type BuildMeterPart + = BuildMeterStripPart + | BuildMeterFigurePart + | BuildMeterGaitPart + | BuildMeterWorkerPart + | BuildMeterSnoozePart + | BuildMeterFinishPart + | BuildMeterEdgePart + | BuildMeterFaultPart + +fn build_meter_part_class(part: BuildMeterPart) -> String { + match part { + BuildMeterStripPart => build_meter_strip_class + BuildMeterFigurePart => build_meter_figure_class + BuildMeterGaitPart => build_meter_gait_class + BuildMeterWorkerPart => build_meter_worker_class + BuildMeterSnoozePart => build_meter_snooze_class + BuildMeterFinishPart => build_meter_finish_class + BuildMeterEdgePart => build_meter_edge_class + BuildMeterFaultPart => build_meter_fault_class + } +} + +// Which changes a row applies to, read from the two stances on the canvas. Every change takes the +// rows for any change; a narrower row matches more of the canvas and comes later, so it replaces a +// wider row for the same part. A sitting worker does not walk off: it is gone when the rouse ends +// and the arriving worker is there. Rising is getting up from the floor: the arriving +// worker stands where the leaving one sat and walks to its place. When either worker is the paused +// outline nobody walks: the two fade across. +type BuildMeterChange + = BuildMeterAnyChange + | BuildMeterFromSitting + | BuildMeterRising + | BuildMeterCrossfade + +fn build_meter_stance_selector(attribute: String, stance: BuildMeterStance) -> String { + build_meter_attribute_selector(target: "", attribute: attribute, value: build_meter_stance_key(stance: stance)) +} + +fn build_meter_change_selectors(change: BuildMeterChange) -> List { + let canvas = join([".", build_meter_canvas_class, ".", build_meter_changing_class], "") + match change { + BuildMeterAnyChange => [canvas] + BuildMeterFromSitting => [join([canvas, build_meter_stance_selector(attribute: build_meter_from_attribute, stance: BuildMeterSitting)], "")] + BuildMeterRising => [join([canvas, build_meter_stance_selector(attribute: build_meter_from_attribute, stance: BuildMeterSitting), build_meter_stance_selector(attribute: build_meter_to_attribute, stance: BuildMeterStanding)], "")] + BuildMeterCrossfade => [ + join([canvas, build_meter_stance_selector(attribute: build_meter_from_attribute, stance: BuildMeterOutline)], ""), + join([canvas, build_meter_stance_selector(attribute: build_meter_to_attribute, stance: BuildMeterOutline)], ""), + ] + } +} + +// What a part does. Appear and vanish are cuts at the row's start, for a worker taking another +// pose; keep is no motion at all, and replaces a wider row's. +type BuildMeterMotion + = BuildMeterHide + | BuildMeterKeep + | BuildMeterFadeIn + | BuildMeterFadeOut + | BuildMeterAppear + | BuildMeterVanish + | BuildMeterWalkOut + | BuildMeterWalkIn + | BuildMeterBob + | BuildMeterWipeUp + | BuildMeterWipeRight + +type BuildMeterCue { + change: BuildMeterChange + side: BuildMeterSide + part: BuildMeterPart + motion: BuildMeterMotion + start_ms: Int + duration_ms: Int +} + +fn build_meter_cue(change: BuildMeterChange, side: BuildMeterSide, part: BuildMeterPart, motion: BuildMeterMotion, start_ms: Int, duration_ms: Int) -> BuildMeterCue { + BuildMeterCue { change: change, side: side, part: part, motion: motion, start_ms: start_ms, duration_ms: duration_ms } +} + +// The change's three beats, under a second together. The rouse: the leaving scene's z's, marks and +// gold rule fade. The walk: a standing worker walks +// in two-unit steps with a bob, and a gold rule draws in from the +// left. The arrival: the arriving pose takes the walker's place, a stop cap, seal or bracket draws +// up from its foot as the walk ends, and the arriving z's drift in last. +data build_meter_rouse_ms: Int = 240 +data build_meter_walk_ms: Int = 320 +data build_meter_arrival_ms: Int = 240 +data build_meter_walk_at: Int = build_meter_rouse_ms +data build_meter_arrive_at: Int = build_meter_rouse_ms + build_meter_walk_ms +data build_meter_change_ms: Int = build_meter_arrive_at + build_meter_arrival_ms +data build_meter_mark_at: Int = build_meter_walk_at + build_meter_walk_ms / 2 +data build_meter_doze_at: Int = build_meter_arrive_at + build_meter_arrival_ms / 3 + +data build_meter_cues: List = [ + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterStripPart, motion: BuildMeterHide, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterSnoozePart, motion: BuildMeterFadeOut, start_ms: 0, duration_ms: build_meter_rouse_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterFinishPart, motion: BuildMeterFadeOut, start_ms: 0, duration_ms: build_meter_rouse_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterEdgePart, motion: BuildMeterFadeOut, start_ms: 0, duration_ms: build_meter_rouse_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterGaitPart, motion: BuildMeterWalkOut, start_ms: build_meter_walk_at, duration_ms: build_meter_walk_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterWorkerPart, motion: BuildMeterBob, start_ms: build_meter_walk_at, duration_ms: build_meter_walk_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterArriving, part: BuildMeterFinishPart, motion: BuildMeterWipeRight, start_ms: build_meter_walk_at, duration_ms: build_meter_walk_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterLeaving, part: BuildMeterFigurePart, motion: BuildMeterVanish, start_ms: build_meter_arrive_at, duration_ms: 0), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterArriving, part: BuildMeterFigurePart, motion: BuildMeterAppear, start_ms: build_meter_arrive_at, duration_ms: 0), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterArriving, part: BuildMeterEdgePart, motion: BuildMeterWipeUp, start_ms: build_meter_mark_at, duration_ms: build_meter_arrival_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterArriving, part: BuildMeterFaultPart, motion: BuildMeterWipeUp, start_ms: build_meter_mark_at, duration_ms: build_meter_arrival_ms), + build_meter_cue(change: BuildMeterAnyChange, side: BuildMeterArriving, part: BuildMeterSnoozePart, motion: BuildMeterFadeIn, start_ms: build_meter_doze_at, duration_ms: build_meter_change_ms - build_meter_doze_at), + build_meter_cue(change: BuildMeterFromSitting, side: BuildMeterLeaving, part: BuildMeterGaitPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterFromSitting, side: BuildMeterLeaving, part: BuildMeterWorkerPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterFromSitting, side: BuildMeterLeaving, part: BuildMeterFigurePart, motion: BuildMeterVanish, start_ms: build_meter_walk_at, duration_ms: 0), + build_meter_cue(change: BuildMeterFromSitting, side: BuildMeterArriving, part: BuildMeterFigurePart, motion: BuildMeterAppear, start_ms: build_meter_walk_at, duration_ms: 0), + build_meter_cue(change: BuildMeterRising, side: BuildMeterArriving, part: BuildMeterGaitPart, motion: BuildMeterWalkIn, start_ms: build_meter_walk_at, duration_ms: build_meter_walk_ms), + build_meter_cue(change: BuildMeterRising, side: BuildMeterArriving, part: BuildMeterWorkerPart, motion: BuildMeterBob, start_ms: build_meter_walk_at, duration_ms: build_meter_walk_ms), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterLeaving, part: BuildMeterGaitPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterLeaving, part: BuildMeterWorkerPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterLeaving, part: BuildMeterFigurePart, motion: BuildMeterFadeOut, start_ms: 0, duration_ms: build_meter_arrive_at), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterArriving, part: BuildMeterGaitPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterArriving, part: BuildMeterWorkerPart, motion: BuildMeterKeep, start_ms: 0, duration_ms: 0), + build_meter_cue(change: BuildMeterCrossfade, side: BuildMeterArriving, part: BuildMeterFigurePart, motion: BuildMeterFadeIn, start_ms: 0, duration_ms: build_meter_arrive_at), +] + +// The walk and the canvas width read the client's numbers: the walk in units, its steps (one per +// two units, at least one), and the leaving and arriving widths. +data build_meter_walk_property: String = "meter-walk" +data build_meter_walk_steps_property: String = "meter-walk-steps" +data build_meter_from_width_property: String = "meter-from-width" +data build_meter_to_width_property: String = "meter-to-width" + +fn build_meter_var(property: String) -> String { + join(["var(--", property, ")"], "") +} + +fn build_meter_clip_frame(offset_percent: Int, inset: String) -> Keyframe { + Keyframe { offset_percent: offset_percent, decls: [decl(prop: ClipPath, value: join(["inset(", inset, ")"], ""))] } +} + +fn build_meter_shift_frame(offset_percent: Int, x: String, y: String) -> Keyframe { + Keyframe { offset_percent: offset_percent, decls: [decl(prop: Transform, value: build_meter_shift(x: x, y: y))] } +} + +data build_meter_fade_in_cycle: MotionCycle = MotionCycle { name: "meter-fade-in", frames: [ + build_meter_opacity_frame(offset_percent: 0, value: "0"), + build_meter_opacity_frame(offset_percent: 100, value: "1"), +] } + +data build_meter_fade_out_cycle: MotionCycle = MotionCycle { name: "meter-fade-out", frames: [ + build_meter_opacity_frame(offset_percent: 0, value: "1"), + build_meter_opacity_frame(offset_percent: 100, value: "0"), +] } + +data build_meter_walk_out_cycle: MotionCycle = MotionCycle { name: "meter-walk-out", frames: [ + build_meter_shift_frame(offset_percent: 0, x: "0", y: "0"), + build_meter_shift_frame(offset_percent: 100, x: build_meter_var(property: build_meter_walk_property), y: "0"), +] } + +data build_meter_walk_in_cycle: MotionCycle = MotionCycle { name: "meter-walk-in", frames: [ + build_meter_shift_frame(offset_percent: 0, x: build_meter_negative(length: build_meter_var(property: build_meter_walk_property)), y: "0"), + build_meter_shift_frame(offset_percent: 100, x: "0", y: "0"), +] } + +data build_meter_bob_cycle: MotionCycle = MotionCycle { name: "meter-bob", frames: [ + build_meter_shift_frame(offset_percent: 0, x: "0", y: "0"), + build_meter_shift_frame(offset_percent: 25, x: "0", y: build_meter_negative(length: scale_var(t: space_1))), + build_meter_shift_frame(offset_percent: 50, x: "0", y: "0"), + build_meter_shift_frame(offset_percent: 75, x: "0", y: build_meter_negative(length: scale_var(t: space_1))), + build_meter_shift_frame(offset_percent: 100, x: "0", y: "0"), +] } + +data build_meter_wipe_up_cycle: MotionCycle = MotionCycle { name: "meter-wipe-up", frames: [ + build_meter_clip_frame(offset_percent: 0, inset: "100% 0 0 0"), + build_meter_clip_frame(offset_percent: 100, inset: "0 0 0 0"), +] } + +data build_meter_wipe_right_cycle: MotionCycle = MotionCycle { name: "meter-wipe-right", frames: [ + build_meter_clip_frame(offset_percent: 0, inset: "0 100% 0 0"), + build_meter_clip_frame(offset_percent: 100, inset: "0 0 0 0"), +] } + +data build_meter_resize_cycle: MotionCycle = MotionCycle { name: "meter-resize", frames: [ + Keyframe { offset_percent: 0, decls: [decl(prop: Width, value: build_meter_var(property: build_meter_from_width_property))] }, + Keyframe { offset_percent: 100, decls: [decl(prop: Width, value: build_meter_var(property: build_meter_to_width_property))] }, +] } + +// Every motion moves in whole units, as the drawing does: fades in four steps, wipes in six, the +// walk one step per two units. +data build_meter_walk_easing: String = join(["steps(", build_meter_var(property: build_meter_walk_steps_property), ", jump-end)"], "") + +type BuildMeterMotionRule + = BuildMeterStillRule { decls: List } + | BuildMeterCycleRule { cycle: MotionCycle, easing: String } + +fn build_meter_motion_rule(motion: BuildMeterMotion) -> BuildMeterMotionRule { + match motion { + BuildMeterHide => BuildMeterStillRule { decls: [decl(prop: Opacity, value: "0")] } + BuildMeterKeep => BuildMeterStillRule { decls: [decl(prop: Animation, value: "none")] } + BuildMeterFadeIn => BuildMeterCycleRule { cycle: build_meter_fade_in_cycle, easing: "steps(4, jump-end)" } + BuildMeterFadeOut => BuildMeterCycleRule { cycle: build_meter_fade_out_cycle, easing: "steps(4, jump-end)" } + BuildMeterAppear => BuildMeterCycleRule { cycle: build_meter_fade_in_cycle, easing: "step-end" } + BuildMeterVanish => BuildMeterCycleRule { cycle: build_meter_fade_out_cycle, easing: "step-end" } + BuildMeterWalkOut => BuildMeterCycleRule { cycle: build_meter_walk_out_cycle, easing: build_meter_walk_easing } + BuildMeterWalkIn => BuildMeterCycleRule { cycle: build_meter_walk_in_cycle, easing: build_meter_walk_easing } + BuildMeterBob => BuildMeterCycleRule { cycle: build_meter_bob_cycle, easing: "step-end" } + BuildMeterWipeUp => BuildMeterCycleRule { cycle: build_meter_wipe_up_cycle, easing: "steps(6, jump-end)" } + BuildMeterWipeRight => BuildMeterCycleRule { cycle: build_meter_wipe_right_cycle, easing: "steps(6, jump-end)" } + } +} + +fn build_meter_cue_selector(cue: BuildMeterCue) -> String { + let drawing = build_meter_attribute_selector(target: join([".", build_meter_art_class], ""), attribute: build_meter_side_attribute, value: build_meter_side_key(side: cue.side)) + join(map(build_meter_change_selectors(change: cue.change), c => join([c, " ", drawing, " .", build_meter_part_class(part: cue.part)], "")), ", ") +} + +fn build_meter_delay_decl(start_ms: Int) -> CssDecl { + decl(prop: AnimationDelay, value: join([to_string(start_ms), "ms"], "")) +} + +fn build_meter_cue_rules(cue: BuildMeterCue) -> List { + let selector = RawSelector { text: build_meter_cue_selector(cue: cue) } + match build_meter_motion_rule(motion: cue.motion) { + BuildMeterStillRule { decls } => [StaticRule { selector: selector, decls: decls }] + BuildMeterCycleRule { cycle, easing } => [ + AmbientRule { selector: selector, cycle: cycle, timing: TimingToken { name: cycle.name, duration_ms: cue.duration_ms, easing: easing }, repeat: RunOnce }, + StaticRule { selector: selector, decls: [build_meter_delay_decl(start_ms: cue.start_ms)] }, + ] + } +} + +// The canvas stacks its drawings in one cell, and while a change plays its width walks from the +// leaving drawing's to the arriving one's with the worker, so nothing beside the meter jumps. +fn build_meter_canvas_rules() -> List { + let changing = RawSelector { text: join([".", build_meter_canvas_class, ".", build_meter_changing_class], "") } + [ + StaticRule { selector: ClassSelector { name: build_meter_canvas_class }, decls: [decl(prop: Display, value: "grid")] }, + StaticRule { selector: RawSelector { text: join([".", build_meter_canvas_class, " > .", build_meter_art_class], "") }, decls: [decl(prop: GridArea, value: "1 / 1")] }, + AmbientRule { selector: changing, cycle: build_meter_resize_cycle, timing: TimingToken { name: build_meter_resize_cycle.name, duration_ms: build_meter_walk_ms, easing: "steps(8, jump-end)" }, repeat: RunOnce }, + StaticRule { selector: changing, decls: [build_meter_delay_decl(start_ms: build_meter_walk_at)] }, + ] +} + +fn build_meter_within(target: String) -> String { + join([".", build_meter_class, " .", target], "") +} + +fn build_meter_in_state(state_class: String, target: String) -> String { + join([".", build_meter_class, ".", state_class, " .", target], "") +} + +fn build_meter_attribute_selector(target: String, attribute: String, value: String) -> String { + join([target, "[", attribute, "=\"", value, "\"]"], "") +} + +fn build_meter_paint_rule(paint: String, value: String) -> BuildRule { + StaticRule { selector: RawSelector { text: join([".", build_meter_class, " ", build_meter_attribute_selector(target: "", attribute: build_meter_paint_attribute, value: paint)], "") }, decls: [ + decl(prop: Fill, value: value), + ] } +} + +fn build_meter_kind_rule(kind: BuildMeterKind) -> BuildRule { + StaticRule { selector: RawSelector { text: join([".", build_meter_class, " ", build_meter_attribute_selector(target: "", attribute: build_meter_kind_attribute, value: build_meter_kind_key(kind: kind))], "") }, decls: [ + decl(prop: Fill, value: palette_var(slot: build_meter_kind_slot(kind: kind))), + ] } +} + +fn build_meter_lid_rest_rule(class_name: String) -> BuildRule { + StaticRule { selector: RawSelector { text: build_meter_attribute_selector(target: build_meter_within(target: class_name), attribute: build_meter_lid_attribute, value: "off") }, decls: [ + decl(prop: Opacity, value: "0"), + ] } +} + +// A glance: the face shifts one unit. With no script the worker glances at the strip on hover or +// focus; a client that knows the pointer's side sets the look attribute, which wins by order. +fn build_meter_glance_rule(trigger_hover: Bool) -> BuildRule { + ResponseRule { subject: ClassSelector { name: build_meter_class }, shape: WithinSubject { target: join([".", build_meter_face_class], "") }, trigger: if trigger_hover { OnHover } else { OnFocusVisible }, transition_props: "transform", timing: restore_gentle, base_decls: [], response_decls: [ + decl(prop: Transform, value: build_meter_shift(x: build_meter_negative(length: scale_var(t: space_1)), y: "0")), + ] } +} + +fn build_meter_look_rule(side: String, x: String) -> BuildRule { + StaticRule { selector: RawSelector { text: join([build_meter_attribute_selector(target: join([".", build_meter_class], ""), attribute: build_meter_look_attribute, value: side), " .", build_meter_face_class], "") }, decls: [ + decl(prop: Transform, value: build_meter_shift(x: x, y: "0")), + ] } +} + +// A lid a client raises or lowers. Only a lid the pose left off is touched, so decoration never +// opens eyes the still has shut. +fn build_meter_lid_state_rule(state_class: String, lid_class: String, opacity: String) -> BuildRule { + ResponseRule { subject: ClassSelector { name: build_meter_class }, shape: WithinSubject { target: build_meter_attribute_selector(target: join([".", lid_class], ""), attribute: build_meter_lid_attribute, value: "off") }, trigger: OnStateClass { class_name: state_class }, transition_props: "opacity", timing: build_meter_doze_timing, base_decls: [], response_decls: [ + decl(prop: Opacity, value: opacity), + ] } +} + + +fn build_meter_visually_hidden_decls() -> List { + [ + decl(prop: Position, value: "absolute"), + decl(prop: Width, value: scale_var(t: space_1)), + decl(prop: Height, value: scale_var(t: space_1)), + decl(prop: Margin, value: build_meter_negative(length: scale_var(t: space_1))), + decl(prop: Overflow, value: "hidden"), + decl(prop: RawProperty { name: "clip" }, value: "rect(0 0 0 0)"), + decl(prop: WhiteSpace, value: "nowrap"), + ] +} + +fn build_meter_reveal_rule(trigger_hover: Bool) -> BuildRule { + ResponseRule { subject: ClassSelector { name: build_meter_class }, shape: WithinSubject { target: join([".", build_meter_digest_class], "") }, trigger: if trigger_hover { OnHover } else { OnFocusVisible }, transition_props: "opacity", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Opacity, value: "1"), + ] } +} + +fn build_meter_rules() -> List { + concat([ + StaticRule { selector: ClassSelector { name: build_meter_class }, decls: [ + decl(prop: Display, value: "inline-block"), + decl(prop: Position, value: "relative"), + decl(prop: Padding, value: scale_var(t: space_4)), + decl(prop: Margin, value: build_meter_negative(length: scale_var(t: space_4))), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: TextDecoration, value: "none"), + ] }, + StaticRule { selector: ClassSelector { name: build_meter_body_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "flex-end"), + decl(prop: Gap, value: scale_var(t: space_2)), + ] }, + StaticRule { selector: ClassSelector { name: build_meter_art_class }, decls: [ + decl(prop: Display, value: "block"), + decl(prop: Overflow, value: "visible"), + ] }, + StaticRule { selector: ClassSelector { name: build_meter_badge_class }, decls: concat(element_text_decls(role: ElementLabel), [ + decl(prop: Display, value: "inline-block"), + decl(prop: TextDecoration, value: "none"), + decl(prop: WhiteSpace, value: "nowrap"), + ]) }, + StaticRule { selector: ClassSelector { name: build_meter_name_class }, decls: build_meter_visually_hidden_decls() }, + StaticRule { selector: ClassSelector { name: build_meter_digest_class }, decls: concat(element_text_decls(role: ElementLabel), [ + decl(prop: Position, value: "absolute"), + decl(prop: RawProperty { name: "top" }, value: "100%"), + decl(prop: RawProperty { name: "left" }, value: scale_var(t: space_0)), + decl(prop: RawProperty { name: "z-index" }, value: "2"), + decl(prop: Padding, value: scale_pair(a: space_2, b: space_4)), + decl(prop: Background, value: palette_var(slot: SlotSurface)), + decl(prop: BoxShadow, value: join(["inset 0 0 0 ", scale_var(t: border_1), " ", palette_var(slot: SlotBorder)], "")), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Width, value: "max-content"), + decl(prop: MaxWidth, value: build_meter_digest_measure), + decl(prop: Opacity, value: "0"), + decl(prop: RawProperty { name: "pointer-events" }, value: "none"), + ]) }, + build_meter_paint_rule(paint: "body", value: palette_var(slot: SlotMeterWorker)), + build_meter_paint_rule(paint: "lid", value: palette_var(slot: SlotMeterWorker)), + build_meter_paint_rule(paint: "light", value: palette_var(slot: SlotMeterWorkerLight)), + build_meter_paint_rule(paint: "shade", value: palette_var(slot: SlotMeterWorkerShade)), + build_meter_paint_rule(paint: "feature", value: palette_var(slot: SlotMeterWorkerFeature)), + build_meter_paint_rule(paint: "edge", value: palette_var(slot: SlotTextDim)), + build_meter_paint_rule(paint: "base", value: palette_var(slot: SlotBorder)), + build_meter_paint_rule(paint: "prop", value: palette_var(slot: SlotTextDim)), + build_meter_paint_rule(paint: "stop", value: palette_var(slot: SlotError)), + build_meter_paint_rule(paint: "finish", value: palette_var(slot: SlotMeterFinish)), + build_meter_paint_rule(paint: "success", value: palette_var(slot: SlotMeterSuccess)), + build_meter_paint_rule(paint: "lit", value: palette_var(slot: SlotSurface)), + ], concat( + map(build_meter_kinds, k => build_meter_kind_rule(kind: k)), + concat([ + build_meter_lid_rest_rule(class_name: build_meter_droop_class), + build_meter_lid_rest_rule(class_name: build_meter_sleep_class), + build_meter_lid_rest_rule(class_name: build_meter_blink_class), + ResponseRule { subject: ClassSelector { name: build_meter_class }, shape: SelfResponse, trigger: OnHover, transition_props: "background-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Background, value: palette_var(slot: SlotHover)), + decl(prop: TextDecoration, value: "none"), + ] }, + ResponseRule { subject: ClassSelector { name: build_meter_class }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_0)), + ] }, + build_meter_reveal_rule(trigger_hover: true), + build_meter_reveal_rule(trigger_hover: false), + build_meter_glance_rule(trigger_hover: true), + build_meter_glance_rule(trigger_hover: false), + build_meter_look_rule(side: build_meter_look_left, x: build_meter_negative(length: scale_var(t: space_1))), + build_meter_look_rule(side: build_meter_look_right, x: scale_var(t: space_1)), + AmbientRule { selector: RawSelector { text: build_meter_within(target: build_meter_blink_class) }, cycle: build_meter_blink_cycle, timing: build_meter_blink_timing, repeat: RunForever }, + StaticRule { selector: RawSelector { text: build_meter_within(target: build_meter_blink_class) }, decls: [ + decl(prop: AnimationDuration, value: join(["var(--", build_meter_blink_period_property, ", 6100ms)"], "")), + decl(prop: AnimationDelay, value: join(["var(--", build_meter_blink_delay_property, ", 0ms)"], "")), + ] }, + AmbientRule { selector: RawSelector { text: build_meter_within(target: build_meter_arm_class) }, cycle: build_meter_tap_cycle, timing: build_meter_tap_timing, repeat: RunForever }, + AmbientRule { selector: RawSelector { text: build_meter_within(target: build_meter_z_class) }, cycle: build_meter_drift_cycle, timing: build_meter_drift_timing, repeat: RunForever }, + StaticRule { selector: RawSelector { text: build_meter_within(target: build_meter_z_class) }, decls: [ + decl(prop: AnimationDuration, value: join(["var(--", build_meter_drift_period_property, ", ", to_string(build_meter_drift_timing.duration_ms), "ms)"], "")), + decl(prop: AnimationDelay, value: join(["var(--", build_meter_drift_delay_property, ", 0ms)"], "")), + ] }, + StaticRule { selector: RawSelector { text: build_meter_attribute_selector(target: build_meter_within(target: build_meter_z_class), attribute: build_meter_z_attribute, value: "2") }, decls: [ + decl(prop: AnimationDelay, value: join(["calc(var(--", build_meter_drift_delay_property, ", 0ms) - var(--", build_meter_drift_period_property, ", ", to_string(build_meter_drift_timing.duration_ms), "ms) / 2)"], "")), + ] }, + build_meter_lid_state_rule(state_class: build_meter_drowsy_class, lid_class: build_meter_droop_class, opacity: "1"), + build_meter_lid_state_rule(state_class: build_meter_asleep_class, lid_class: build_meter_droop_class, opacity: "1"), + build_meter_lid_state_rule(state_class: build_meter_asleep_class, lid_class: build_meter_sleep_class, opacity: "1"), + build_meter_lid_state_rule(state_class: build_meter_waking_class, lid_class: build_meter_droop_class, opacity: "0"), + build_meter_lid_state_rule(state_class: build_meter_waking_class, lid_class: build_meter_sleep_class, opacity: "0"), + AmbientRule { selector: RawSelector { text: build_meter_in_state(state_class: build_meter_beat_class, target: build_meter_worker_class) }, cycle: build_meter_nudge_cycle, timing: build_meter_beat_timing, repeat: RunOnce }, + AmbientRule { selector: RawSelector { text: build_meter_in_state(state_class: build_meter_beat_class, target: build_meter_blink_class) }, cycle: build_meter_long_blink_cycle, timing: build_meter_beat_timing, repeat: RunOnce }, + touch_target_rule(selector: ClassSelector { name: build_meter_class }, shape: TouchTargetCentredLink), + ], + concat(build_meter_canvas_rules(), flat_map(build_meter_cues, c => build_meter_cue_rules(cue: c)))))) +} diff --git a/dag/gunbc/site/element/button.dag b/dag/gunbc/site/element/button.dag new file mode 100644 index 00000000000..ae709104e51 --- /dev/null +++ b/dag/gunbc/site/element/button.dag @@ -0,0 +1,134 @@ +module gunbc.site.element.button + +import std.types { Int, List, String } +import std.markup { Fragment, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import extdeps.google.issue_tracker { + IssueActionButtonIdiom, IssueButtonContainedPrimary, IssueButtonOutlined, IssueButtonText, + issue_button_idiom_wire, + IssueActionAvailability, IssueActionEnabled, IssueActionDisabled, + issue_tracker_measured_button, +} +import gunbc.design.material { BuildRule, StaticRule, ResponseRule, SelfResponse, OnHover, OnFocusVisible, respond_fast, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_border, space_0, space_2, border_2 } +import gunbc.site.element.tokens { + radius_element, height_button, height_button_small, button_padding, button_small_padding, button_border, +} +import gunbc.site.element.palette { + palette_var, SlotText, SlotSurface, SlotBorder, SlotAction, SlotActionHover, SlotOnAction, +} +import gunbc.site.element.text { element_text_decls, element_control_small_size_decls, ElementControl } +import gunbc.site.element.touch_target { touch_target_rule, TouchTargetBox } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, Border, BorderColor, BorderRadius, Color, Cursor, Display, Height, + JustifyContent, MinHeight, Outline, Padding, WhiteSpace, RawProperty, +} + +// Button: one control in the product's three idioms (filled, outlined, text) and two sizes. The +// idiom and the availability are the upstream vocabulary (extdeps.google.issue_tracker), so a +// button's kind has one spelling from the model to the attribute a rule selects on. An +// unavailable button stays visible and is not interactive. Every button here is a plain +// press control; a form's submit control is added when a form adopts this element. +type ButtonSize + = ButtonSmall + | ButtonRegular + +type Button { + label: String + idiom: IssueActionButtonIdiom + size: ButtonSize + availability: IssueActionAvailability +} + +data button_class: String = "el-button" +data button_idiom_attribute: String = "data-button-idiom" +data button_size_attribute: String = "data-button-size" + +fn button_size_key(size: ButtonSize) -> String { + match size { + ButtonSmall => "small" + ButtonRegular => "regular" + } +} + +fn button_availability_attributes(availability: IssueActionAvailability) -> List { + match availability { + IssueActionEnabled => [] + IssueActionDisabled => [attr(name: "disabled", value: "")] + } +} + +fn button(input: Button) -> Fragment { + el_attrs("button", concat([ + attr(name: "class", value: button_class), + attr(name: "type", value: "button"), + attr(name: button_idiom_attribute, value: issue_button_idiom_wire(idiom: input.idiom)), + attr(name: button_size_attribute, value: button_size_key(size: input.size)), + ], button_availability_attributes(availability: input.availability)), [txt(input.label)]) +} + +fn button_size_selector(size: ButtonSize) -> String { + join([".", button_class, "[", button_size_attribute, "=\"", button_size_key(size: size), "\"]"], "") +} + +fn button_idiom_selector(idiom: IssueActionButtonIdiom) -> String { + join([".", button_class, "[", button_idiom_attribute, "=\"", issue_button_idiom_wire(idiom: idiom), "\"]"], "") +} + +// A disabled button draws its label and its boundary as fractions of one ink (the upstream +// button row); the ink here is the text colour of the current scheme. +fn button_disabled_mix(pct: Int) -> String { + join(["color-mix(in srgb, ", palette_var(slot: SlotText), " ", to_string(pct), "%, transparent)"], "") +} + +fn button_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: button_class }, decls: concat([ + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: JustifyContent, value: "center"), + decl(prop: Height, value: scale_var(t: height_button)), + decl(prop: MinHeight, value: scale_var(t: height_button)), + decl(prop: Padding, value: scale_pair(a: space_0, b: button_padding)), + decl(prop: Border, value: scale_border(w: button_border, color_var: "transparent")), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Background, value: "transparent"), + decl(prop: Cursor, value: "pointer"), + decl(prop: WhiteSpace, value: "nowrap"), + ], element_text_decls(role: ElementControl)) }, + StaticRule { selector: RawSelector { text: button_size_selector(size: ButtonSmall) }, decls: concat([ + decl(prop: Height, value: scale_var(t: height_button_small)), + decl(prop: MinHeight, value: scale_var(t: height_button_small)), + decl(prop: Padding, value: scale_pair(a: space_0, b: button_small_padding)), + ], element_control_small_size_decls()) }, + StaticRule { selector: RawSelector { text: button_idiom_selector(idiom: IssueButtonContainedPrimary) }, decls: [ + decl(prop: Background, value: palette_var(slot: SlotAction)), + decl(prop: Color, value: palette_var(slot: SlotOnAction)), + ] }, + StaticRule { selector: RawSelector { text: button_idiom_selector(idiom: IssueButtonOutlined) }, decls: [ + decl(prop: Background, value: palette_var(slot: SlotSurface)), + decl(prop: BorderColor, value: palette_var(slot: SlotBorder)), + ] }, + StaticRule { selector: RawSelector { text: join([".", button_class, ":disabled"], "") }, decls: [ + decl(prop: Color, value: button_disabled_mix(pct: issue_tracker_measured_button.disabled_label_pct)), + decl(prop: Cursor, value: "default"), + ] }, + StaticRule { selector: RawSelector { text: join([button_idiom_selector(idiom: IssueButtonContainedPrimary), ":disabled"], "") }, decls: [ + decl(prop: Background, value: button_disabled_mix(pct: issue_tracker_measured_button.disabled_boundary_pct)), + ] }, + StaticRule { selector: RawSelector { text: join([button_idiom_selector(idiom: IssueButtonOutlined), ":disabled"], "") }, decls: [ + decl(prop: Background, value: "transparent"), + decl(prop: BorderColor, value: button_disabled_mix(pct: issue_tracker_measured_button.disabled_boundary_pct)), + ] }, + ResponseRule { subject: RawSelector { text: join([button_idiom_selector(idiom: IssueButtonContainedPrimary), ":enabled"], "") }, shape: SelfResponse, trigger: OnHover, transition_props: "background-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Background, value: palette_var(slot: SlotActionHover)), + ] }, + ResponseRule { subject: ClassSelector { name: button_class }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_2)), + ] }, + touch_target_rule(selector: ClassSelector { name: button_class }, shape: TouchTargetBox), + touch_target_rule(selector: RawSelector { text: button_size_selector(size: ButtonSmall) }, shape: TouchTargetBox), + ] +} diff --git a/dag/gunbc/site/element/card.dag b/dag/gunbc/site/element/card.dag new file mode 100644 index 00000000000..def796f4e3d --- /dev/null +++ b/dag/gunbc/site/element/card.dag @@ -0,0 +1,100 @@ +module gunbc.site.element.card + +import std.types { List, String } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.design.material { BuildRule, StaticRule, decl } +import gunbc.design.scale { scale_var, scale_border, space_0, space_8 } +import gunbc.site.element.tokens { + radius_element, card_padding, card_border, card_leading_bar, height_card_label_row, +} +import gunbc.site.element.palette { + palette_var, palette_action_ink_name, SlotSurface, SlotBorder, SlotAction, SlotLeadingBar, +} +import gunbc.site.element.text { element_text_decls, ElementBody, ElementCardLabel } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, Border, BorderLeft, BorderRadius, CustomProperty, Display, Gap, Margin, + MarginLeft, MinHeight, MinWidth, Padding, +} + +// Card: the one surface content sits on. A card may open with a label row (an uppercase label, +// with optional trailing controls pushed to the far edge) and may thicken its leading border into +// a bar, which the product uses for the description and status-update cards. The card owns its +// own appearance only; where it sits and how wide it is belong to the composition around it. +type CardEdge + = CardEdgePlain + | CardEdgeBar + +type CardLabelRow { + label: String + trailing: List +} + +type Card { + edge: CardEdge + label_row: CardLabelRow? + body: List +} + +data card_class: String = "el-card" +data card_label_row_class: String = "el-card-label-row" +data card_label_class: String = "el-card-label" +data card_trailing_class: String = "el-card-trailing" +data card_edge_attribute: String = "data-card-edge" + +fn card_edge_key(edge: CardEdge) -> String { + match edge { + CardEdgePlain => "plain" + CardEdgeBar => "bar" + } +} + +fn card_label_row_nodes(row: CardLabelRow?) -> List { + match row { + Absent => [] + Present { value: r } => [ + el_attrs("div", [attr(name: "class", value: card_label_row_class)], concat( + [el_attrs("h2", [attr(name: "class", value: card_label_class)], [txt(r.label)])], + if count(r.trailing) == 0 { [] } else { [el_attrs("div", [attr(name: "class", value: card_trailing_class)], r.trailing)] })), + ] + } +} + +fn card(input: Card) -> Fragment { + el_attrs("section", [ + attr(name: "class", value: card_class), + attr(name: card_edge_attribute, value: card_edge_key(edge: input.edge)), + ], concat(card_label_row_nodes(row: input.label_row), input.body)) +} + +fn card_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: card_class }, decls: concat([ + decl(prop: CustomProperty { name: palette_action_ink_name }, value: palette_var(slot: SlotAction)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + decl(prop: Background, value: palette_var(slot: SlotSurface)), + decl(prop: Border, value: scale_border(w: card_border, color_var: palette_var(slot: SlotBorder))), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Padding, value: scale_var(t: card_padding)), + ], element_text_decls(role: ElementBody)) }, + StaticRule { selector: RawSelector { text: join([".", card_class, "[", card_edge_attribute, "=\"", card_edge_key(edge: CardEdgeBar), "\"]"], "") }, decls: [ + decl(prop: BorderLeft, value: scale_border(w: card_leading_bar, color_var: palette_var(slot: SlotLeadingBar))), + ] }, + StaticRule { selector: ClassSelector { name: card_label_row_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: MinHeight, value: scale_var(t: height_card_label_row)), + ] }, + StaticRule { selector: ClassSelector { name: card_label_class }, decls: concat([ + decl(prop: Margin, value: scale_var(t: space_0)), + ], element_text_decls(role: ElementCardLabel)) }, + StaticRule { selector: ClassSelector { name: card_trailing_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: MarginLeft, value: "auto"), + ] }, + ] +} diff --git a/dag/gunbc/site/element/chip.dag b/dag/gunbc/site/element/chip.dag new file mode 100644 index 00000000000..9bebadae45a --- /dev/null +++ b/dag/gunbc/site/element/chip.dag @@ -0,0 +1,53 @@ +module gunbc.site.element.chip + +import std.types { Int, List, String } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import extdeps.google.issue_tracker { issue_tracker_measured_header_chip } +import gunbc.design.material { BuildRule, StaticRule, CssDecl, decl } +import gunbc.design.scale { scale_var, scale_pair, space_0 } +import gunbc.site.element.tokens { radius_element, height_chip, chip_padding } +import gunbc.site.element.palette { palette_var, SlotText } +import gunbc.site.element.text { element_text_decls, ElementIdentifier } +import extdeps.languages.css.selectors { ClassSelector } +import extdeps.languages.css.properties { AlignItems, Background, BorderRadius, Display, MinHeight, Padding, WhiteSpace } + +// Chip: an identifier shown as the product's plain chip (the one its header draws the component +// path and the ancestor chain in). It takes the identifier's text and nothing else. The surface +// is exported as declarations so that an element which IS a chip with structure inside it +// (gunbc.site.element.node_chain) draws the same surface from the same tokens under its own +// class, and no page restyles this one. +type Chip { + text: String +} + +data chip_class: String = "el-chip" + +fn chip(input: Chip) -> Fragment { + el_attrs("span", [attr(name: "class", value: chip_class)], [txt(input.text)]) +} + +// The fill is a fraction of the text ink over whatever is behind the chip, as measured, so it +// reads on a card, on the ground and on a closed row in both schemes. +fn chip_fill() -> String { + join(["color-mix(in srgb, ", palette_var(slot: SlotText), " ", to_string(issue_tracker_measured_header_chip.fill_ink_pct), "%, transparent)"], "") +} + +fn chip_surface_decls() -> List { + concat([ + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: MinHeight, value: scale_var(t: height_chip)), + decl(prop: Padding, value: scale_pair(a: space_0, b: chip_padding)), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Background, value: chip_fill()), + ], element_text_decls(role: ElementIdentifier)) +} + +fn chip_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: chip_class }, decls: concat(chip_surface_decls(), [ + decl(prop: WhiteSpace, value: "nowrap"), + ]) }, + ] +} diff --git a/dag/gunbc/site/element/field_list.dag b/dag/gunbc/site/element/field_list.dag new file mode 100644 index 00000000000..7a5c3df4325 --- /dev/null +++ b/dag/gunbc/site/element/field_list.dag @@ -0,0 +1,412 @@ +module gunbc.site.element.field_list + +import std.types { Bool, Int, List, String } +import std.markup { Fragment, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap_issue_status { IssueDisplayStatus, issue_display_status_label } +import gunbc.design.material { BuildRule, StaticRule, ResponseRule, MediaRule, SelfResponse, OnHover, OnFocusVisible, respond_fast, CssDecl, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_border, height_control, space_0, space_1, space_2, space_3, space_4, space_6, space_8, radius_999, border_2 } +import gunbc.site.element.tokens { + radius_element, height_button_small, height_field_row, field_label_max, + field_label_gap, field_avatar, +} +import gunbc.site.element.palette { palette_var, palette_action_ink_var, SlotTextDim, SlotAction, SlotHover } +import gunbc.site.element.text { element_text_decls, ElementBody, ElementLabel } +import gunbc.site.element.status_chip { status_chip } +import gunbc.site.element.build_meter { CompactWorkSummary, build_meter } +import gunbc.site.element.touch_target { touch_target_rule, touch_target_decls, TouchTargetBox, TouchTargetInlineLink } +import extdeps.languages.css.media_queries { coarse_pointer } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, Border, FlexDirection, JustifyContent, BorderRadius, Color, Cursor, Display, Flex, FontFamily, FontSize, + Gap, GridTemplateColumns, Height, LineHeight, ListStyle, Margin, MarginLeft, MaxWidth, MinHeight, MinWidth, + Outline, Overflow, Padding, Position, TextAlign, TextDecoration, TextOverflow, WhiteSpace, Width, + RawProperty, +} + +// FieldList: a list of labelled facts at one row pitch, the product's issue metadata sidebar +// (extdeps.google.issue_tracker issue_tracker_measured_field_rail). A row is a label in a fixed +// column and ONE value on one line: a value longer than its column is cut with an ellipsis, never +// wrapped, and its full text stays in the DOM for assistive technology and in `title` for the +// pointer. Every control a value holds (a disclosure, a link, a button) has a hit area of at least +// the small button height and a drawn height inside the row pitch, so every closed row is the same +// height whatever it holds. An opened disclosure grows its own row; nothing else does. +// +// The value is a closed set of arms, chosen by the caller from the fact it has; the element never +// inspects a string to decide how to draw it. + +// A person, as a face and the identity text beside it. `full` is the complete identity (name and +// address) when it says more than `identity`. +type FieldPerson { + face: Fragment + identity: String + full: String? +} + +type FieldLink { + text: String + href: String + full: String? +} + +// How a disclosure opens: below its summary (the summary stays as the heading of what it shows), +// or in place of it (the summary is the resting value and the panel is its editor). +type FieldDisclosureOpen + = FieldOpensBelow + | FieldOpensInPlace + +type FieldValue + = FieldText { text: String, full: String? } + | FieldLinks { links: List, unlinked: List } + | FieldAction { text: String, attributes: List } + | FieldIdentity { person: FieldPerson } + | FieldStatusControl { status: IssueDisplayStatus, detail: List } + | FieldIdentityControl { person: FieldPerson, action: String, editor: List } + | FieldWork { summary: CompactWorkSummary, link_attributes: List } + | FieldAbsent { reason: String } + +type FieldRow { + label: String + value: FieldValue +} + +type FieldList { + rows: List +} + +data field_list_class: String = "el-field-list" +data field_list_row_class: String = "el-field-list-row" +data field_list_label_class: String = "el-field-list-label" +data field_list_value_class: String = "el-field-list-value" +data field_list_text_class: String = "el-field-list-text" +data field_list_link_class: String = "el-field-list-link" +data field_list_action_class: String = "el-field-list-action" +data field_list_face_class: String = "el-field-list-face" +data field_list_person_class: String = "el-field-list-person" +data field_list_absent_class: String = "el-field-list-absent" +data field_list_assistive_class: String = "el-field-list-assistive" +data field_list_disclosure_class: String = "el-field-list-disclosure" +data field_list_summary_class: String = "el-field-list-summary" +data field_list_panel_class: String = "el-field-list-panel" +data field_list_value_attribute: String = "data-field-value" +data field_list_open_attribute: String = "data-field-open" + +// What an absent value draws. "--" means absent and nothing else (element spec 5c). +data field_list_absent_mark: String = "--" + +fn field_value_key(value: FieldValue) -> String { + match value { + FieldText { text: _, full: _ } => "text" + FieldLinks { links: _, unlinked: _ } => "links" + FieldAction { text: _, attributes: _ } => "action" + FieldIdentity { person: _ } => "identity" + FieldStatusControl { status: _, detail: _ } => "status-control" + FieldIdentityControl { person: _, action: _, editor: _ } => "identity-control" + FieldWork { summary: _, link_attributes: _ } => "work" + FieldAbsent { reason: _ } => "absent" + } +} + +fn field_open_key(opens: FieldDisclosureOpen) -> String { + match opens { + FieldOpensBelow => "below" + FieldOpensInPlace => "in-place" + } +} + +fn field_full_or(full: String?, text: String) -> String { + match full { + Present { value: f } => f + Absent => text + } +} + +// Text the eye does not see and assistive technology reads: the full value where the visible +// text is an abbreviation of it. +fn field_assistive(text: String) -> Fragment { + el_attrs("span", [attr(name: "class", value: field_list_assistive_class)], [txt(text)]) +} + +// One line of text. Where the caller abbreviated the value, the abbreviation is hidden from +// assistive technology and the full value is read instead. +// A line that is not itself inside a control takes keyboard focus, so the full value it may cut +// is disclosed on focus as well as on hover (element spec 5c: a title is reachable by keyboard). +fn field_text_attributes(title: String, focusable: Bool) -> List { + concat([attr(name: "class", value: field_list_text_class), attr(name: "title", value: title)], if focusable { [attr(name: "tabindex", value: "0")] } else { [] }) +} + +fn field_text_line(text: String, full: String?, focusable: Bool) -> Fragment { + match full { + Absent => el_attrs("span", field_text_attributes(title: text, focusable: focusable), [txt(text)]) + Present { value: f } => + if f == text { + el_attrs("span", field_text_attributes(title: text, focusable: focusable), [txt(text)]) + } else { + el_attrs("span", field_text_attributes(title: f, focusable: focusable), [ + el_attrs("span", [attr(name: "aria-hidden", value: "true")], [txt(text)]), + field_assistive(text: f), + ]) + } + } +} + +fn field_link_node(link: FieldLink) -> Fragment { + el_attrs("a", [ + attr(name: "class", value: field_list_link_class), + attr(name: "href", value: link.href), + attr(name: "title", value: field_full_or(full: link.full, text: link.text)), + ], [txt(link.text)]) +} + +fn field_links_title(links: List, unlinked: List) -> String { + join(concat(map(links, l => field_full_or(full: l.full, text: l.text)), unlinked), ", ") +} + +// Links and the related names no link can be made for, in order, on one line. +fn field_links_nodes(links: List, unlinked: List) -> List { + let parts = concat(map(links, l => field_link_node(link: l)), map(unlinked, u => txt(u))) + fold(parts, init: [] as List, f: (acc, p) => if count(acc) == 0 { [p] } else { concat(acc, [txt(", "), p]) }) +} + +fn field_person_nodes(person: FieldPerson, focusable: Bool) -> List { + [ + el_attrs("span", [attr(name: "class", value: field_list_face_class), attr(name: "aria-hidden", value: "true")], [person.face]), + field_text_line(text: person.identity, full: person.full, focusable: focusable), + ] +} + +fn field_disclosure(opens: FieldDisclosureOpen, summary_attributes: List, summary: List, panel: List) -> Fragment { + el_attrs("details", [ + attr(name: "class", value: field_list_disclosure_class), + attr(name: field_list_open_attribute, value: field_open_key(opens: opens)), + ], [ + el_attrs("summary", concat([attr(name: "class", value: field_list_summary_class)], summary_attributes), summary), + el_attrs("div", [attr(name: "class", value: field_list_panel_class)], panel), + ]) +} + +fn field_value_nodes(value: FieldValue) -> List { + match value { + FieldText { text, full } => [field_text_line(text: text, full: full, focusable: true)] + FieldLinks { links, unlinked } => [ + el_attrs("span", [attr(name: "class", value: field_list_text_class), attr(name: "title", value: field_links_title(links: links, unlinked: unlinked))], field_links_nodes(links: links, unlinked: unlinked)), + ] + FieldAction { text, attributes } => [ + el_attrs("button", concat([ + attr(name: "class", value: field_list_action_class), + attr(name: "type", value: "button"), + attr(name: "title", value: text), + ], attributes), [txt(text)]), + ] + FieldIdentity { person } => [el_attrs("span", [attr(name: "class", value: field_list_person_class)], field_person_nodes(person: person, focusable: true))] + FieldStatusControl { status, detail } => + [field_disclosure(opens: FieldOpensBelow, summary_attributes: [attr(name: "title", value: issue_display_status_label(status: status))], summary: [status_chip(status: status)], panel: detail)] + FieldIdentityControl { person, action, editor } => + [field_disclosure(opens: FieldOpensInPlace, summary_attributes: [attr(name: "aria-label", value: join([action, ": ", field_full_or(full: person.full, text: person.identity)], ""))], summary: field_person_nodes(person: person, focusable: false), panel: editor)] + FieldWork { summary, link_attributes } => [build_meter(summary: summary, link_attributes: link_attributes)] + FieldAbsent { reason } => [ + el_attrs("span", [attr(name: "class", value: join([field_list_text_class, " ", field_list_absent_class], "")), attr(name: "title", value: reason)], [ + el_attrs("span", [attr(name: "aria-hidden", value: "true")], [txt(field_list_absent_mark)]), + field_assistive(text: reason), + ]), + ] + } +} + +fn field_row(row: FieldRow) -> Fragment { + el_attrs("div", [ + attr(name: "class", value: field_list_row_class), + attr(name: field_list_value_attribute, value: field_value_key(value: row.value)), + ], [ + el_attrs("dt", [attr(name: "class", value: field_list_label_class)], [txt(row.label)]), + el_attrs("dd", [attr(name: "class", value: field_list_value_class)], field_value_nodes(value: row.value)), + ]) +} + +fn field_list(input: FieldList) -> Fragment { + el_attrs("dl", [attr(name: "class", value: field_list_class)], map(input.rows, r => field_row(row: r))) +} + +// Rules. A link keeps the line's height and reaches the hit area through its inline padding; +// the face slot sizes whatever face it is given to the slot. + +fn field_selector(class_name: String) -> String { + join([".", class_name], "") +} + +fn field_negative(t: String) -> String { + join(["calc(", t, " * -1)"], "") +} + +// The label column is one fixed width, the measured maximum, shared by every row of a list (each +// row is a subgrid of the list), so a value starts at the same x in every list on every page. A +// label is one line and is never cut; a label wider than the column is a wording problem to report, +// not a layout to absorb. The product's 23% clamp resolves to its minimum at the rail's width and +// would make the column follow the labels, so neither the percentage nor the content width is used. +fn field_label_column() -> String { + scale_var(t: field_label_max) +} + +fn field_single_line_decls() -> List { + [ + decl(prop: Display, value: "block"), + decl(prop: Position, value: "relative"), + decl(prop: MinWidth, value: scale_var(t: space_0)), + decl(prop: Overflow, value: "hidden"), + decl(prop: TextOverflow, value: "ellipsis"), + decl(prop: WhiteSpace, value: "nowrap"), + ] +} + +fn field_focus_rule(class_name: String) -> BuildRule { + ResponseRule { subject: ClassSelector { name: class_name }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_2)), + ] } +} + +// Under a coarse pointer every control a value holds is a touch target (gunbc.site.element +// touch_target), and the list keeps one row pitch by taking the target as its pitch: a row that +// holds a control and a row that holds text stay the same height, and a disclosure's summary fills +// its row instead of sitting inside it with the fine-pointer margins. +fn field_touch_pitch_rule(class_name: String) -> BuildRule { + MediaRule { condition: coarse_pointer, selector: ClassSelector { name: class_name }, decls: [ + decl(prop: MinHeight, value: scale_var(t: height_control)), + ] } +} + +fn field_list_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: field_list_class }, decls: [ + decl(prop: Display, value: "grid"), + decl(prop: GridTemplateColumns, value: join([field_label_column(), " minmax(0, 1fr)"], "")), + decl(prop: RawProperty { name: "column-gap" }, value: scale_var(t: field_label_gap)), + decl(prop: Margin, value: scale_var(t: space_0)), + decl(prop: Padding, value: scale_var(t: space_0)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_row_class }, decls: [ + decl(prop: Display, value: "grid"), + decl(prop: RawProperty { name: "grid-column" }, value: "1 / -1"), + decl(prop: GridTemplateColumns, value: "subgrid"), + decl(prop: AlignItems, value: "start"), + decl(prop: MinHeight, value: scale_var(t: height_field_row)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_label_class }, decls: concat(element_text_decls(role: ElementLabel), [ + decl(prop: Margin, value: scale_var(t: space_0)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: MinHeight, value: scale_var(t: height_field_row)), + decl(prop: MaxWidth, value: scale_var(t: field_label_max)), + decl(prop: WhiteSpace, value: "nowrap"), + ]) }, + StaticRule { selector: ClassSelector { name: field_list_value_class }, decls: concat(element_text_decls(role: ElementBody), [ + decl(prop: Margin, value: scale_var(t: space_0)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: JustifyContent, value: "center"), + decl(prop: MinHeight, value: scale_var(t: height_field_row)), + ]) }, + StaticRule { selector: ClassSelector { name: field_list_text_class }, decls: field_single_line_decls() }, + StaticRule { selector: RawSelector { text: join([field_selector(class_name: field_list_text_class), ":focus-within"], "") }, decls: [ + decl(prop: WhiteSpace, value: "normal"), + decl(prop: RawProperty { name: "overflow-wrap" }, value: "anywhere"), + ] }, + StaticRule { selector: ClassSelector { name: field_list_absent_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_assistive_class }, decls: [ + decl(prop: Position, value: "absolute"), + decl(prop: Width, value: scale_var(t: space_1)), + decl(prop: Height, value: scale_var(t: space_1)), + decl(prop: Overflow, value: "hidden"), + decl(prop: WhiteSpace, value: "nowrap"), + decl(prop: RawProperty { name: "clip-path" }, value: "inset(50%)"), + ] }, + StaticRule { selector: ClassSelector { name: field_list_link_class }, decls: [ + decl(prop: Color, value: palette_action_ink_var()), + decl(prop: TextDecoration, value: "none"), + decl(prop: Padding, value: scale_pair(a: space_3, b: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_action_class }, decls: concat(field_single_line_decls(), [ + decl(prop: FontFamily, value: "inherit"), + decl(prop: FontSize, value: "inherit"), + decl(prop: LineHeight, value: "inherit"), + decl(prop: Color, value: palette_action_ink_var()), + decl(prop: Background, value: "transparent"), + decl(prop: Border, value: "0"), + decl(prop: Padding, value: scale_var(t: space_0)), + decl(prop: MinHeight, value: scale_var(t: height_button_small)), + decl(prop: MaxWidth, value: "100%"), + decl(prop: TextAlign, value: "left"), + decl(prop: Cursor, value: "pointer"), + ]) }, + StaticRule { selector: ClassSelector { name: field_list_person_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_face_class }, decls: [ + decl(prop: Display, value: "inline-flex"), + decl(prop: Flex, value: "none"), + decl(prop: Width, value: scale_var(t: field_avatar)), + decl(prop: Height, value: scale_var(t: field_avatar)), + decl(prop: BorderRadius, value: scale_var(t: radius_999)), + decl(prop: Overflow, value: "hidden"), + ] }, + StaticRule { selector: RawSelector { text: join([field_selector(class_name: field_list_face_class), " > *"], "") }, decls: [ + decl(prop: Width, value: "100%"), + decl(prop: Height, value: "100%"), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_disclosure_class }, decls: [ + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: field_list_summary_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: ListStyle, value: "none"), + decl(prop: Cursor, value: "pointer"), + decl(prop: MinWidth, value: scale_var(t: space_0)), + decl(prop: MinHeight, value: scale_var(t: height_button_small)), + decl(prop: Padding, value: scale_pair(a: space_0, b: space_4)), + decl(prop: Margin, value: scale_pair(a: space_6, b: space_0)), + decl(prop: MarginLeft, value: field_negative(t: scale_var(t: space_4))), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Color, value: "inherit"), + ] }, + StaticRule { selector: RawSelector { text: join([field_selector(class_name: field_list_disclosure_class), "[", field_list_open_attribute, "=\"", field_open_key(opens: FieldOpensInPlace), "\"][open] > ", field_selector(class_name: field_list_summary_class)], "") }, decls: [ + decl(prop: Display, value: "none"), + ] }, + StaticRule { selector: ClassSelector { name: field_list_panel_class }, decls: [ + decl(prop: Padding, value: scale_pair(a: space_4, b: space_0)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + ResponseRule { subject: ClassSelector { name: field_list_summary_class }, shape: SelfResponse, trigger: OnHover, transition_props: "background-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Background, value: palette_var(slot: SlotHover)), + ] }, + ResponseRule { subject: ClassSelector { name: field_list_link_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: TextDecoration, value: "underline"), + ] }, + ResponseRule { subject: ClassSelector { name: field_list_action_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: TextDecoration, value: "underline"), + ] }, + field_focus_rule(class_name: field_list_summary_class), + field_focus_rule(class_name: field_list_text_class), + field_focus_rule(class_name: field_list_link_class), + field_focus_rule(class_name: field_list_action_class), + field_touch_pitch_rule(class_name: field_list_row_class), + field_touch_pitch_rule(class_name: field_list_label_class), + field_touch_pitch_rule(class_name: field_list_value_class), + MediaRule { condition: coarse_pointer, selector: ClassSelector { name: field_list_summary_class }, decls: concat(touch_target_decls(shape: TouchTargetBox), [ + decl(prop: RawProperty { name: "margin-block" }, value: scale_var(t: space_0)), + ]) }, + touch_target_rule(selector: ClassSelector { name: field_list_action_class }, shape: TouchTargetBox), + touch_target_rule(selector: ClassSelector { name: field_list_link_class }, shape: TouchTargetInlineLink), + ] +} diff --git a/dag/gunbc/site/element/node_chain.dag b/dag/gunbc/site/element/node_chain.dag new file mode 100644 index 00000000000..7cd2b3d8ba8 --- /dev/null +++ b/dag/gunbc/site/element/node_chain.dag @@ -0,0 +1,238 @@ +module gunbc.site.element.node_chain + +import std.types { Bool, Int, List, String } +import std.markup { Fragment, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap.roadmap_hierarchy_view { + NodePresentation, NodeChain, NodeChainRooted, NodeChainRefused, node_chain_refusal_text, +} +import gunbc.design.material { BuildRule, StaticRule, ResponseRule, SelfResponse, OnHover, OnFocusVisible, respond_fast, CssDecl, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_border, space_0, space_2, space_4, space_24, border_2 } +import gunbc.site.element.tokens { radius_element } +import gunbc.site.element.palette { palette_var, SlotText, SlotTextDim, SlotAction, SlotError } +import gunbc.site.element.chip { chip_surface_decls } +import gunbc.site.element.touch_target { touch_target_rule, TouchTargetBox, TouchTargetCentredLink } +import extdeps.languages.css.selectors { ClassSelector } +import extdeps.languages.css.properties { + AlignItems, Background, BorderRadius, Color, Cursor, Display, FlexWrap, Gap, JustifyContent, ListStyle, Margin, + MinHeight, MinWidth, Outline, Padding, TextDecoration, WhiteSpace, RawProperty, +} + +// NodeChain: where a node sits, as one chip. It takes the membership lineage the hierarchy view +// derived (gunbc.roadmap.roadmap_hierarchy_view NodeChain) and lists every level's slug from the +// root to the node itself (owner ruling R1: the chain shows slugs). Each ancestor is a link to +// its route that carries its title, as a tooltip and in its accessible name, so the title does +// not depend on hovering; the last level is the node itself and is marked as the current page. +// +// A long chain keeps its first level and its last two and puts the middle behind a control that +// states how many levels it holds and opens them in place. The omitted levels stay in the +// document inside that control, so nothing is dropped, only folded. A lineage the view refused +// renders the view's own reason and no levels: a prefix of a chain is not a chain. + +// The most levels shown without folding. At this many or fewer every level is shown. +data node_chain_unfolded_levels: Int = 4 + +// How a chain is laid out. `Folded` holds at least two middle levels by construction of +// node_chain_layout: a control that hid one level would take as much room as the level. +type NodeChainLayout + = NodeChainUnfolded { ancestors: List } + | NodeChainFolded { first: NodePresentation, middle: List, last: NodePresentation } + +fn node_chain_layout(ancestors: List) -> NodeChainLayout { + if count(ancestors) + 1 <= node_chain_unfolded_levels { + NodeChainUnfolded { ancestors: ancestors } + } else { + match ancestors.first() { + Absent => NodeChainUnfolded { ancestors: ancestors } + Present { value: first } => match ancestors.last() { + Absent => NodeChainUnfolded { ancestors: ancestors } + Present { value: last } => NodeChainFolded { + first: first, + middle: ancestors.skip(n: 1).take(n: count(ancestors) - 2), + last: last, + } + } + } + } +} + +data node_chain_class: String = "el-node-chain" +data node_chain_levels_class: String = "el-node-chain-levels" +data node_chain_level_class: String = "el-node-chain-level" +data node_chain_separator_class: String = "el-node-chain-separator" +data node_chain_link_class: String = "el-node-chain-link" +data node_chain_current_class: String = "el-node-chain-current" +data node_chain_fold_class: String = "el-node-chain-fold" +data node_chain_fold_count_class: String = "el-node-chain-fold-count" +data node_chain_refused_class: String = "el-node-chain-refused" +data node_chain_state_attribute: String = "data-chain-state" +data node_chain_label: String = "Issue hierarchy" +data node_chain_separator_text: String = "›" + +fn node_chain_level_name(node: NodePresentation) -> String { + join([node.slug, ": ", node.title], "") +} + +fn node_chain_separator() -> Fragment { + el_attrs("span", [attr(name: "class", value: node_chain_separator_class), attr(name: "aria-hidden", value: "true")], [txt(node_chain_separator_text)]) +} + +// A level is preceded by a separator unless it opens the chain. +fn node_chain_level(opens: Bool, content: Fragment) -> Fragment { + el_attrs("li", [attr(name: "class", value: node_chain_level_class)], if opens { [content] } else { [node_chain_separator(), content] }) +} + +fn node_chain_ancestor(node: NodePresentation) -> Fragment { + el_attrs("a", [ + attr(name: "class", value: node_chain_link_class), + attr(name: "href", value: node.route), + attr(name: "title", value: node.title), + attr(name: "aria-label", value: node_chain_level_name(node: node)), + ], [txt(node.slug)]) +} + +fn node_chain_current(node: NodePresentation) -> Fragment { + el_attrs("span", [ + attr(name: "class", value: node_chain_current_class), + attr(name: "aria-current", value: "page"), + attr(name: "title", value: node.title), + attr(name: "aria-label", value: node_chain_level_name(node: node)), + ], [txt(node.slug)]) +} + +fn node_chain_fold_count_text(n: Int) -> String { + join(["… ", to_string(n)], "") +} + +fn node_chain_fold_label(n: Int) -> String { + join([to_string(n), " more levels"], "") +} + +// The folded middle: a disclosure whose summary states the count and whose body is the levels +// themselves, each still a link. It opens without script. The shared shell styles every +// disclosure summary (gunbc.site.document: a control-height row with an open/closed chevron); the +// chevron is kept as this control's open state, and the rule below restates the geometry a chip +// needs. +fn node_chain_fold(middle: List) -> Fragment { + el_attrs("details", [attr(name: "class", value: node_chain_fold_class)], [ + el_attrs("summary", [ + attr(name: "class", value: node_chain_fold_count_class), + attr(name: "aria-label", value: node_chain_fold_label(n: count(middle))), + attr(name: "title", value: node_chain_fold_label(n: count(middle))), + ], [txt(node_chain_fold_count_text(n: count(middle)))]), + el_attrs("ol", [attr(name: "class", value: node_chain_levels_class)], map(middle, node => node_chain_level(opens: false, content: node_chain_ancestor(node: node)))), + ]) +} + +fn node_chain_ancestor_levels(ancestors: List) -> List { + match node_chain_layout(ancestors: ancestors) { + NodeChainUnfolded { ancestors: shown } => + map(enumerate(shown), pair => node_chain_level(opens: pair.first == 0, content: node_chain_ancestor(node: pair.second))) + NodeChainFolded { first, middle, last } => [ + node_chain_level(opens: true, content: node_chain_ancestor(node: first)), + node_chain_level(opens: false, content: node_chain_fold(middle: middle)), + node_chain_level(opens: false, content: node_chain_ancestor(node: last)), + ] + } +} + +fn node_chain_state_key(chain: NodeChain) -> String { + match chain { + NodeChainRooted { ancestors: _, self_node: _ } => "rooted" + NodeChainRefused { refusal: _ } => "refused" + } +} + +fn node_chain_nodes(chain: NodeChain) -> List { + match chain { + NodeChainRefused { refusal } => [ + el_attrs("span", [attr(name: "class", value: node_chain_refused_class)], [txt(node_chain_refusal_text(refusal: refusal))]), + ] + NodeChainRooted { ancestors, self_node } => [ + el_attrs("ol", [attr(name: "class", value: node_chain_levels_class)], concat( + node_chain_ancestor_levels(ancestors: ancestors), + [node_chain_level(opens: count(ancestors) == 0, content: node_chain_current(node: self_node))])), + ] + } +} + +fn node_chain(chain: NodeChain) -> Fragment { + el_attrs("nav", [ + attr(name: "class", value: node_chain_class), + attr(name: "aria-label", value: node_chain_label), + attr(name: node_chain_state_attribute, value: node_chain_state_key(chain: chain)), + ], node_chain_nodes(chain: chain)) +} + +fn node_chain_row_decls() -> List { + [ + decl(prop: Display, value: "inline-flex"), + decl(prop: FlexWrap, value: "wrap"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_pair(a: space_0, b: space_4)), + ] +} + +fn node_chain_focus_rule(class_name: String) -> BuildRule { + ResponseRule { subject: ClassSelector { name: class_name }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_2)), + ] } +} + +fn node_chain_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: node_chain_class }, decls: concat(chip_surface_decls(), [ + decl(prop: FlexWrap, value: "wrap"), + ]) }, + StaticRule { selector: ClassSelector { name: node_chain_levels_class }, decls: concat(node_chain_row_decls(), [ + decl(prop: Margin, value: scale_var(t: space_0)), + decl(prop: Padding, value: scale_var(t: space_0)), + decl(prop: ListStyle, value: "none"), + ]) }, + StaticRule { selector: ClassSelector { name: node_chain_level_class }, decls: concat(node_chain_row_decls(), [ + decl(prop: WhiteSpace, value: "nowrap"), + ]) }, + StaticRule { selector: ClassSelector { name: node_chain_separator_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ] }, + StaticRule { selector: ClassSelector { name: node_chain_link_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + decl(prop: TextDecoration, value: "none"), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + ] }, + StaticRule { selector: ClassSelector { name: node_chain_current_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotText)), + ] }, + StaticRule { selector: ClassSelector { name: node_chain_fold_class }, decls: node_chain_row_decls() }, + StaticRule { selector: ClassSelector { name: node_chain_fold_count_class }, decls: [ + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: JustifyContent, value: "center"), + decl(prop: Gap, value: scale_var(t: space_4)), + decl(prop: MinWidth, value: scale_var(t: space_24)), + decl(prop: MinHeight, value: scale_var(t: space_0)), + decl(prop: Padding, value: scale_pair(a: space_0, b: space_4)), + decl(prop: ListStyle, value: "none"), + decl(prop: Cursor, value: "pointer"), + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: WhiteSpace, value: "nowrap"), + ] }, + StaticRule { selector: ClassSelector { name: node_chain_refused_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotError)), + ] }, + ResponseRule { subject: ClassSelector { name: node_chain_link_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Color, value: palette_var(slot: SlotText)), + decl(prop: TextDecoration, value: "underline"), + ] }, + ResponseRule { subject: ClassSelector { name: node_chain_fold_count_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Background, value: "transparent"), + decl(prop: Color, value: palette_var(slot: SlotText)), + ] }, + node_chain_focus_rule(class_name: node_chain_link_class), + node_chain_focus_rule(class_name: node_chain_fold_count_class), + touch_target_rule(selector: ClassSelector { name: node_chain_link_class }, shape: TouchTargetCentredLink), + touch_target_rule(selector: ClassSelector { name: node_chain_fold_count_class }, shape: TouchTargetBox), + ] +} diff --git a/dag/gunbc/site/element/node_row.dag b/dag/gunbc/site/element/node_row.dag new file mode 100644 index 00000000000..55efee34df6 --- /dev/null +++ b/dag/gunbc/site/element/node_row.dag @@ -0,0 +1,325 @@ +module gunbc.site.element.node_row + +import std.types { Bool, Int, List, String } +import std.markup { Fragment, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap.roadmap_hierarchy_view { + NodePresentation, NodeStatus, NodeStatusDerived, NodeStatusAccepted, NodeStatusWithheld, NodeStatusUnavailable, node_status_is_closed, + RelationRowChildren, RowChildrenNotCarried, RowChildrenExpandable, +} +import gunbc.design.material { BuildRule, StaticRule, ResponseRule, SelfResponse, OnHover, OnFocusVisible, respond_fast, CssDecl, decl } +import gunbc.design.scale { ScaleToken, scale_var, scale_pair, scale_border, space_0, space_2, space_8, border_2 } +import gunbc.site.element.tokens { + height_relation_row, height_grid_row, relation_cell_padding, relation_row_line, relation_level_indent, +} +import gunbc.site.element.palette { palette_var, palette_action_ink_var, SlotText, SlotTextDim, SlotBorder, SlotAction, SlotClosedRow } +import gunbc.site.element.text { element_text_decls, ElementBody, ElementLabel } +import gunbc.site.element.chip { Chip, chip } +import gunbc.site.element.priority_mark { PriorityMark, priority_mark, priority_mark_label_inset } +import gunbc.roadmap_priority { issue_priority_explanation } +import gunbc.site.element.status_chip { status_chip, status_chip_of, StatusChipAccepted, StatusChipWithheld } +import gunbc.site.element.touch_target { touch_target_rule, TouchTargetCentredLink } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, BorderBottom, Color, CustomProperty, Display, Flex, Gap, Height, + MinWidth, Outline, Padding, TextAlign, TextDecoration, VerticalAlign, + WhiteSpace, Width, RawProperty, +} + +// NodeRow: the one row a node is shown as, wherever a list of nodes is shown. It takes the node's +// canonical presentation (gunbc.roadmap.roadmap_hierarchy_view NodePresentation) and renders it; +// it derives nothing. The title links to the node's route, the priority is the PriorityMark, the +// slug is a Chip and the status is the StatusChip, so each of those has one drawing here and +// everywhere else. A status the producer could not derive is said to be unavailable, with the +// producer's reason, and is never drawn as a status. +// +// A row is a table row, and the header row is built here from the same column roster, so a +// heading and the cells under it cannot fall out of step. + +// How tall the row is: the relation tables' density, or the results grid's. Both are arms of one +// type because a row is one element at two heights (owner ruling R3). The relation tables use the +// table density today; the grid density is drawn on the specimen and is what the results grid +// takes when it adopts this row. +type NodeRowDensity + = NodeRowDensityTable + | NodeRowDensityGrid + +data node_row_densities: List = [NodeRowDensityTable, NodeRowDensityGrid] + +fn node_row_density_key(density: NodeRowDensity) -> String { + match density { + NodeRowDensityTable => "table" + NodeRowDensityGrid => "grid" + } +} + +fn node_row_density_height(density: NodeRowDensity) -> ScaleToken { + match density { + NodeRowDensityTable => height_relation_row + NodeRowDensityGrid => height_grid_row + } +} + +// Where the row stands in a tree. A flat row is a row of a relation that carries no reference to +// its rows' children. A tree row is indented one step per level below the table's own rows and +// says what the view says about its children. The hierarchy view refers to a row's sub-issues by +// count and does not load them (RelationRowChildren), so the row cannot open them in place: it +// states the count as a link to the node's own page, where they are listed. A count of zero is a +// node with no sub-issues and adds nothing to the row. +type NodeRowPlace + = NodeRowFlat + | NodeRowInTree { depth: Int, children: RelationRowChildren } + +type NodeRow { + node: NodePresentation + place: NodeRowPlace + density: NodeRowDensity +} + +type NodeRowColumn + = NodeRowColumnTitle + | NodeRowColumnPriority + | NodeRowColumnId + | NodeRowColumnStatus + +data node_row_columns: List = [NodeRowColumnTitle, NodeRowColumnPriority, NodeRowColumnId, NodeRowColumnStatus] + +fn node_row_column_key(column: NodeRowColumn) -> String { + match column { + NodeRowColumnTitle => "title" + NodeRowColumnPriority => "priority" + NodeRowColumnId => "id" + NodeRowColumnStatus => "status" + } +} + +fn node_row_column_heading(column: NodeRowColumn) -> String { + match column { + NodeRowColumnTitle => "Title" + NodeRowColumnPriority => "P" + NodeRowColumnId => "Id" + NodeRowColumnStatus => "Status" + } +} + +fn node_row_column_count() -> Int { + count(node_row_columns) +} + +data node_row_class: String = "el-node-row" +data node_row_head_class: String = "el-node-row-head" +data node_row_cell_class: String = "el-node-row-cell" +data node_row_heading_class: String = "el-node-row-heading" +data node_row_lead_class: String = "el-node-row-lead" +data node_row_indent_class: String = "el-node-row-indent" +data node_row_children_class: String = "el-node-row-children" +data node_row_link_class: String = "el-node-row-link" +data node_row_status_unavailable_class: String = "el-node-row-status-unavailable" +data node_row_density_attribute: String = "data-node-density" +data node_row_state_attribute: String = "data-node-state" +data node_row_depth_attribute: String = "data-node-depth" +data node_row_column_attribute: String = "data-node-column" +data node_row_children_attribute: String = "data-node-children" +data node_row_height_property: String = "el-node-row-height" + +data node_row_status_unavailable_text: String = "Status unavailable" + +fn node_row_state_key(closed: Bool) -> String { + if closed { "closed" } else { "open" } +} + +fn node_row_indents(depth: Int) -> List { + if depth <= 0 { + [] + } else { + concat([el_attrs("span", [attr(name: "class", value: node_row_indent_class), attr(name: "aria-hidden", value: "true")], [])], node_row_indents(depth: depth - 1)) + } +} + +fn node_row_children_text(n: Int) -> String { + if n == 1 { "1 sub-issue" } else { join([to_string(n), " sub-issues"], "") } +} + +// The reference to a row's sub-issues: a plain link to the node's page, labelled with the count. +fn node_row_children_nodes(node: NodePresentation, children: RelationRowChildren) -> List { + match children { + RowChildrenNotCarried => [] + RowChildrenExpandable { count: n } => + if n == 0 { + [] + } else { + [el_attrs("a", [ + attr(name: "class", value: node_row_children_class), + attr(name: "href", value: node.route), + attr(name: node_row_children_attribute, value: to_string(n)), + ], [txt(node_row_children_text(n: n))])] + } + } +} + +fn node_row_place_indents(place: NodeRowPlace) -> List { + match place { + NodeRowFlat => [] + NodeRowInTree { depth, children: _ } => node_row_indents(depth: depth) + } +} + +fn node_row_place_children(node: NodePresentation, place: NodeRowPlace) -> List { + match place { + NodeRowFlat => [] + NodeRowInTree { depth: _, children } => node_row_children_nodes(node: node, children: children) + } +} + +fn node_row_place_depth(place: NodeRowPlace) -> Int { + match place { + NodeRowFlat => 0 + NodeRowInTree { depth, children: _ } => depth + } +} + +fn node_row_status_nodes(status: NodeStatus) -> List { + match status { + NodeStatusDerived { status: derived, closed: _ } => [status_chip(status: derived)] + NodeStatusAccepted => [status_chip_of(subject: StatusChipAccepted)] + NodeStatusWithheld => [status_chip_of(subject: StatusChipWithheld)] + NodeStatusUnavailable { reason } => [ + el_attrs("span", [ + attr(name: "class", value: node_row_status_unavailable_class), + attr(name: "title", value: reason), + attr(name: "aria-label", value: join([node_row_status_unavailable_text, ": ", reason], "")), + ], [txt(node_row_status_unavailable_text)]), + ] + } +} + +fn node_row_cell_nodes(input: NodeRow, column: NodeRowColumn) -> List { + match column { + NodeRowColumnTitle => [ + el_attrs("div", [attr(name: "class", value: node_row_lead_class)], concat(node_row_place_indents(place: input.place), concat([ + el_attrs("a", [attr(name: "class", value: node_row_link_class), attr(name: "href", value: input.node.route)], [txt(input.node.title)]), + ], node_row_place_children(node: input.node, place: input.place)))), + ] + NodeRowColumnPriority => [priority_mark(input: PriorityMark { priority: input.node.priority.priority, explanation: Present { value: issue_priority_explanation(value: input.node.priority) } })] + NodeRowColumnId => [chip(input: Chip { text: input.node.slug })] + NodeRowColumnStatus => node_row_status_nodes(status: input.node.status) + } +} + +fn node_row_cell_attributes(class_name: String, column: NodeRowColumn) -> List { + [attr(name: "class", value: class_name), attr(name: node_row_column_attribute, value: node_row_column_key(column: column))] +} + +fn node_row(input: NodeRow) -> Fragment { + el_attrs("tr", [ + attr(name: "class", value: node_row_class), + attr(name: node_row_density_attribute, value: node_row_density_key(density: input.density)), + attr(name: node_row_state_attribute, value: node_row_state_key(closed: node_status_is_closed(status: input.node.status))), + attr(name: node_row_depth_attribute, value: to_string(node_row_place_depth(place: input.place))), + ], map(node_row_columns, c => el_attrs("td", node_row_cell_attributes(class_name: node_row_cell_class, column: c), node_row_cell_nodes(input: input, column: c)))) +} + +fn node_row_header(density: NodeRowDensity) -> Fragment { + el_attrs("tr", [ + attr(name: "class", value: node_row_head_class), + attr(name: node_row_density_attribute, value: node_row_density_key(density: density)), + ], map(node_row_columns, c => el_attrs("th", concat(node_row_cell_attributes(class_name: node_row_heading_class, column: c), [attr(name: "scope", value: "col")]), [txt(node_row_column_heading(column: c))]))) +} + +fn node_row_attribute_selector(class_name: String, attribute: String, value: String) -> String { + join([".", class_name, "[", attribute, "=\"", value, "\"]"], "") +} + +fn node_row_height_var() -> String { + join(["var(--", node_row_height_property, ")"], "") +} + +fn node_row_density_rule(class_name: String, density: NodeRowDensity) -> BuildRule { + StaticRule { selector: RawSelector { text: node_row_attribute_selector(class_name: class_name, attribute: node_row_density_attribute, value: node_row_density_key(density: density)) }, decls: [ + decl(prop: CustomProperty { name: node_row_height_property }, value: scale_var(t: node_row_density_height(density: density))), + ] } +} + +fn node_row_cell_decls() -> List { + [ + decl(prop: Height, value: node_row_height_var()), + decl(prop: Padding, value: scale_pair(a: space_0, b: relation_cell_padding)), + decl(prop: BorderBottom, value: scale_border(w: relation_row_line, color_var: palette_var(slot: SlotBorder))), + decl(prop: VerticalAlign, value: "middle"), + decl(prop: TextAlign, value: "left"), + decl(prop: WhiteSpace, value: "nowrap"), + ] +} + +fn node_row_title_column_selector(class_name: String) -> String { + node_row_attribute_selector(class_name: class_name, attribute: node_row_column_attribute, value: node_row_column_key(column: NodeRowColumnTitle)) +} + +fn node_row_closed_selector() -> String { + node_row_attribute_selector(class_name: node_row_class, attribute: node_row_state_attribute, value: node_row_state_key(closed: true)) +} + +fn node_row_rules() -> List { + concat( + concat( + map(node_row_densities, d => node_row_density_rule(class_name: node_row_class, density: d)), + map(node_row_densities, d => node_row_density_rule(class_name: node_row_head_class, density: d))), + [ + StaticRule { selector: ClassSelector { name: node_row_cell_class }, decls: concat(node_row_cell_decls(), element_text_decls(role: ElementBody)) }, + StaticRule { selector: ClassSelector { name: node_row_heading_class }, decls: concat(node_row_cell_decls(), element_text_decls(role: ElementLabel)) }, + StaticRule { selector: RawSelector { text: node_row_title_column_selector(class_name: node_row_cell_class) }, decls: [ + decl(prop: Width, value: "100%"), + decl(prop: WhiteSpace, value: "normal"), + ] }, + StaticRule { selector: RawSelector { text: node_row_attribute_selector(class_name: node_row_heading_class, attribute: node_row_column_attribute, value: node_row_column_key(column: NodeRowColumnPriority)) }, decls: [ + decl(prop: RawProperty { name: "padding-left" }, value: join(["calc(", scale_var(t: relation_cell_padding), " + ", priority_mark_label_inset(), ")"], "")), + ] }, + StaticRule { selector: RawSelector { text: node_row_title_column_selector(class_name: node_row_heading_class) }, decls: [ + decl(prop: Width, value: "100%"), + ] }, + StaticRule { selector: RawSelector { text: join([node_row_closed_selector(), " .", node_row_cell_class], "") }, decls: [ + decl(prop: Background, value: palette_var(slot: SlotClosedRow)), + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ] }, + StaticRule { selector: ClassSelector { name: node_row_lead_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: node_row_indent_class }, decls: [ + decl(prop: Flex, value: "none"), + decl(prop: Width, value: scale_var(t: relation_level_indent)), + ] }, + StaticRule { selector: ClassSelector { name: node_row_children_class }, decls: concat(element_text_decls(role: ElementLabel), [ + decl(prop: Flex, value: "none"), + decl(prop: Color, value: palette_action_ink_var()), + decl(prop: TextDecoration, value: "none"), + decl(prop: WhiteSpace, value: "nowrap"), + ]) }, + StaticRule { selector: ClassSelector { name: node_row_link_class }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotText)), + decl(prop: TextDecoration, value: "none"), + ] }, + StaticRule { selector: RawSelector { text: join([node_row_closed_selector(), " .", node_row_link_class], "") }, decls: [ + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ] }, + StaticRule { selector: ClassSelector { name: node_row_status_unavailable_class }, decls: element_text_decls(role: ElementLabel) }, + ResponseRule { subject: ClassSelector { name: node_row_link_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: TextDecoration, value: "underline"), + ] }, + ResponseRule { subject: ClassSelector { name: node_row_children_class }, shape: SelfResponse, trigger: OnHover, transition_props: "color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: TextDecoration, value: "underline"), + ] }, + ResponseRule { subject: ClassSelector { name: node_row_children_class }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_2)), + ] }, + ResponseRule { subject: ClassSelector { name: node_row_link_class }, shape: SelfResponse, trigger: OnFocusVisible, transition_props: "outline-color", timing: respond_fast, base_decls: [], response_decls: [ + decl(prop: Outline, value: scale_border(w: border_2, color_var: palette_var(slot: SlotAction))), + decl(prop: RawProperty { name: "outline-offset" }, value: scale_var(t: space_2)), + ] }, + touch_target_rule(selector: ClassSelector { name: node_row_children_class }, shape: TouchTargetCentredLink), + touch_target_rule(selector: ClassSelector { name: node_row_link_class }, shape: TouchTargetCentredLink), + ]) +} diff --git a/dag/gunbc/site/element/palette.dag b/dag/gunbc/site/element/palette.dag new file mode 100644 index 00000000000..e7a4b0171bf --- /dev/null +++ b/dag/gunbc/site/element/palette.dag @@ -0,0 +1,218 @@ +module gunbc.site.element.palette + +import std.types { Bool, List, String } +import extdeps.color.srgb { Srgb8 } +import extdeps.google.issue_tracker { + issue_tracker_visual_tokens, + issue_tracker_measured_palette_light, + issue_tracker_measured_palette_dark, + issue_tracker_measured_code_ink_light, + issue_tracker_measured_relation_table, +} +import gunbc.design.palette { Swatch, swatch_paint } +import gunbc.design.material { CssDecl, decl } +import extdeps.languages.css.properties { CustomProperty } + +// The one palette of the internal tools frontend. Each slot is one custom property, and each +// scheme answers every slot through a total match, so a slot defined for day and missing for +// night does not compile. The values are ADOPTED: most read an upstream measurement by reference +// (extdeps.google.issue_tracker), and the ones that depart from it or that the capture never +// reached are spelled here with the reason, never written back into the upstream rows. +type PaletteSlot + = SlotText + | SlotTextDim + | SlotBorder + | SlotHover + | SlotSubtle + | SlotHighlight + | SlotAction + | SlotActionHover + | SlotError + | SlotGround + | SlotSurface + | SlotLink + | SlotOnAction + | SlotCodeInk + | SlotLeadingBar + | SlotClosedRow + | SlotStatusReady + | SlotStatusAssigned + | SlotStatusBlocked + | SlotMeterReason + | SlotMeterRead + | SlotMeterWrite + | SlotMeterEdit + | SlotMeterRun + | SlotMeterSearch + | SlotMeterReport + | SlotMeterWorker + | SlotMeterWorkerLight + | SlotMeterWorkerShade + | SlotMeterWorkerFeature + | SlotMeterFinish + | SlotMeterSuccess + +data palette_slots: List = [ + SlotText, SlotTextDim, SlotBorder, SlotHover, SlotSubtle, SlotHighlight, SlotAction, + SlotActionHover, SlotError, SlotGround, SlotSurface, SlotLink, SlotOnAction, SlotCodeInk, + SlotLeadingBar, SlotClosedRow, SlotStatusReady, SlotStatusAssigned, SlotStatusBlocked, + SlotMeterReason, SlotMeterRead, SlotMeterWrite, SlotMeterEdit, SlotMeterRun, SlotMeterSearch, SlotMeterReport, + SlotMeterWorker, SlotMeterWorkerLight, SlotMeterWorkerShade, SlotMeterWorkerFeature, SlotMeterFinish, SlotMeterSuccess, +] + +fn palette_slot_name(slot: PaletteSlot) -> String { + match slot { + SlotText => "issue-text" + SlotTextDim => "issue-text-dim" + SlotBorder => "issue-border" + SlotHover => "issue-bg-hover" + SlotSubtle => "issue-bg-subtle" + SlotHighlight => "issue-highlight" + SlotAction => "issue-action" + SlotActionHover => "issue-action-hover" + SlotError => "issue-error" + SlotGround => "issue-ground" + SlotSurface => "issue-surface" + SlotLink => "issue-link" + SlotOnAction => "issue-on-action" + SlotCodeInk => "issue-code-ink" + SlotLeadingBar => "issue-leading-bar" + SlotClosedRow => "issue-closed-row" + SlotStatusReady => "status-ready-ink" + SlotStatusAssigned => "status-assigned-ink" + SlotStatusBlocked => "status-blocked-ink" + SlotMeterReason => "meter-reason" + SlotMeterRead => "meter-read" + SlotMeterWrite => "meter-write" + SlotMeterEdit => "meter-edit" + SlotMeterRun => "meter-run" + SlotMeterSearch => "meter-search" + SlotMeterReport => "meter-report" + SlotMeterWorker => "meter-worker" + SlotMeterWorkerLight => "meter-worker-light" + SlotMeterWorkerShade => "meter-worker-shade" + SlotMeterWorkerFeature => "meter-worker-feature" + SlotMeterFinish => "meter-finish" + SlotMeterSuccess => "meter-success" + } +} + +fn palette_var(slot: PaletteSlot) -> String { + join(["var(--", palette_slot_name(slot: slot), ")"], "") +} + +// Day. Hover, highlight, pressed action and error come from the 2026-09-22 token row; the rest +// from the 2026-10-04 measured row. SlotLink is a local adoption: the action colour on the grey +// ground is 4.27:1 and fails AA at body size, so link-coloured text there takes the product's own +// darker action colour (its hover value), which clears it. SlotOnAction is the label on a filled +// button, which the product draws in the card colour. SlotClosedRow is the ground of a closed +// relation row, read from the relation table's own measurement. The three status inks are house colours +// (green ready, blue assigned, amber blocked) with no upstream counterpart; they moved here +// from gunbc.site.status so the frontend has one palette. +// The meter slots are the build meter's (gunbc.site.element.build_meter). The seven kind colours +// are the ones the worker scene's prototype settled, one per operation kind. The worker paints are +// the brand's stark colourway of the gunbai mark: ink on cream by day, stone on void by night +// (gunb-ai/frontend legacy/moodboard.html, the same source gunbc.site.robot_mark cites); the light +// and shade paints are that body colour's two neighbours, used for a one-pixel highlight and +// shadow. The finish colour marks a published attempt's strip. +fn palette_day(slot: PaletteSlot) -> Srgb8 { + match slot { + SlotText => issue_tracker_measured_palette_light.text + SlotTextDim => issue_tracker_measured_palette_light.text_muted + SlotBorder => issue_tracker_measured_palette_light.hairline + SlotHover => issue_tracker_visual_tokens.background_hover + SlotSubtle => issue_tracker_measured_palette_light.field + SlotHighlight => issue_tracker_visual_tokens.highlight + SlotAction => issue_tracker_measured_palette_light.action + SlotActionHover => issue_tracker_visual_tokens.action_hover + SlotError => issue_tracker_visual_tokens.error + SlotGround => issue_tracker_measured_palette_light.ground + SlotSurface => issue_tracker_measured_palette_light.card + SlotLink => issue_tracker_visual_tokens.action_hover + SlotOnAction => issue_tracker_measured_palette_light.card + SlotCodeInk => issue_tracker_measured_code_ink_light + SlotLeadingBar => issue_tracker_measured_palette_light.leading_bar + SlotClosedRow => issue_tracker_measured_relation_table.closed_row_ground + SlotStatusReady => Srgb8 { r: 38, g: 105, b: 68 } + SlotStatusAssigned => Srgb8 { r: 54, g: 90, b: 155 } + SlotStatusBlocked => Srgb8 { r: 142, g: 83, b: 21 } + SlotMeterReason => Srgb8 { r: 79, g: 75, b: 176 } + SlotMeterRead => Srgb8 { r: 59, g: 143, b: 208 } + SlotMeterWrite => Srgb8 { r: 226, g: 87, b: 58 } + SlotMeterEdit => Srgb8 { r: 238, g: 162, b: 44 } + SlotMeterRun => Srgb8 { r: 67, g: 160, b: 101 } + SlotMeterSearch => Srgb8 { r: 23, g: 160, b: 151 } + SlotMeterReport => Srgb8 { r: 180, g: 86, b: 159 } + SlotMeterWorker => Srgb8 { r: 15, g: 15, b: 15 } + SlotMeterWorkerLight => Srgb8 { r: 61, g: 60, b: 57 } + SlotMeterWorkerShade => Srgb8 { r: 0, g: 0, b: 0 } + SlotMeterWorkerFeature => Srgb8 { r: 244, g: 241, b: 236 } + SlotMeterFinish => Srgb8 { r: 184, g: 134, b: 46 } + SlotMeterSuccess => Srgb8 { r: 52, g: 168, b: 83 } + } +} + +// Night. Text, muted text, hairline, action, field, ground and card are the measured dark row. +// The dark capture did not reach a hovered row, a selection, a pressed action or an error, so +// those four keep the values the owner directed on 2026-09-23; they are local until a capture +// shows them. A filled button's label takes the dark ground, the link needs no adjustment on the +// dark ground, and code takes the text ink because the dark capture shows no inline code. +// A closed relation row was not captured in the dark scheme either; it takes the same grey the +// hovered row was given, which is the pairing the light capture shows. +fn palette_night(slot: PaletteSlot) -> Srgb8 { + match slot { + SlotText => issue_tracker_measured_palette_dark.text + SlotTextDim => issue_tracker_measured_palette_dark.text_muted + SlotBorder => issue_tracker_measured_palette_dark.hairline + SlotHover => Srgb8 { r: 41, g: 42, b: 45 } + SlotSubtle => issue_tracker_measured_palette_dark.field + SlotHighlight => Srgb8 { r: 26, g: 58, b: 92 } + SlotAction => issue_tracker_measured_palette_dark.action + SlotActionHover => Srgb8 { r: 174, g: 203, b: 250 } + SlotError => Srgb8 { r: 242, g: 139, b: 130 } + SlotGround => issue_tracker_measured_palette_dark.ground + SlotSurface => issue_tracker_measured_palette_dark.card + SlotLink => issue_tracker_measured_palette_dark.action + SlotOnAction => issue_tracker_measured_palette_dark.ground + SlotCodeInk => issue_tracker_measured_palette_dark.text + SlotLeadingBar => issue_tracker_measured_palette_dark.leading_bar + SlotClosedRow => Srgb8 { r: 41, g: 42, b: 45 } + SlotStatusReady => Srgb8 { r: 146, g: 201, b: 166 } + SlotStatusAssigned => Srgb8 { r: 146, g: 181, b: 238 } + SlotStatusBlocked => Srgb8 { r: 226, g: 181, b: 109 } + SlotMeterReason => Srgb8 { r: 141, g: 138, b: 242 } + SlotMeterRead => Srgb8 { r: 92, g: 176, b: 242 } + SlotMeterWrite => Srgb8 { r: 243, g: 118, b: 88 } + SlotMeterEdit => Srgb8 { r: 246, g: 186, b: 78 } + SlotMeterRun => Srgb8 { r: 95, g: 197, b: 134 } + SlotMeterSearch => Srgb8 { r: 57, g: 198, b: 186 } + SlotMeterReport => Srgb8 { r: 216, g: 130, b: 198 } + SlotMeterWorker => Srgb8 { r: 206, g: 197, b: 182 } + SlotMeterWorkerLight => Srgb8 { r: 232, g: 225, b: 214 } + SlotMeterWorkerShade => Srgb8 { r: 156, g: 146, b: 130 } + SlotMeterWorkerFeature => Srgb8 { r: 21, g: 21, b: 20 } + SlotMeterFinish => Srgb8 { r: 217, g: 178, b: 95 } + SlotMeterSuccess => Srgb8 { r: 129, g: 201, b: 149 } + } +} + +fn palette_color(night: Bool, slot: PaletteSlot) -> Srgb8 { + if night { palette_night(slot: slot) } else { palette_day(slot: slot) } +} + +// The ink for action-coloured text is contextual: it defaults to the link colour, which is safe +// on both the ground and a card, and a card restores the product's action colour for its own +// contents (gunbc.site.element.card). Elements read this property, never SlotAction, for text. +data palette_action_ink_name: String = "issue-action-ink" + +fn palette_action_ink_var() -> String { + join(["var(--", palette_action_ink_name, ")"], "") +} + +fn palette_decls(night: Bool) -> List { + concat( + map(palette_slots, s => decl( + prop: CustomProperty { name: palette_slot_name(slot: s) }, + value: swatch_paint(sw: Swatch { name: palette_slot_name(slot: s), color: palette_color(night: night, slot: s) }))), + [decl(prop: CustomProperty { name: palette_action_ink_name }, value: palette_var(slot: SlotLink))]) +} diff --git a/dag/gunbc/site/element/priority_mark.dag b/dag/gunbc/site/element/priority_mark.dag new file mode 100644 index 00000000000..57bb94d23ad --- /dev/null +++ b/dag/gunbc/site/element/priority_mark.dag @@ -0,0 +1,118 @@ +module gunbc.site.element.priority_mark + +import std.types { List, String } +import std.markup { Fragment, Attribute, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap_priority { RoadmapPriority, PriorityP0, PriorityP1, PriorityP2, PriorityP3, roadmap_priority_label } +import gunbc.design.material { BuildRule, StaticRule, CssDecl, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_border, space_0 } +import gunbc.site.element.tokens { radius_element, height_chip, priority_mark_width, priority_mark_border, priority_mark_padding } +import gunbc.site.element.palette { palette_var, SlotAction, SlotOnAction, SlotTextDim } +import gunbc.site.element.text { element_text_decls, ElementBody } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, Border, BorderColor, BorderRadius, BoxSizing, Color, Display, Height, JustifyContent, MinWidth, Padding, +} + +// PriorityMark: the one drawing of a priority, wherever one is shown. It takes the typed priority +// (gunbc.roadmap_priority) and prints that module's label, with the caller's explanation of how the +// priority was derived as its title when the caller has one. +// +// Every mark is the same box: the measured width and chip height, the measured border on every +// side (transparent where the form draws none) and the measured side padding, with the label set +// at the box's left. So the label's left edge is the same in every row of a column whatever the +// priority, and a column heading aligns with it by insetting its text by the border and padding. +// The forms differ only in colour. P1 is the product's ring in the action colour, as measured. P0 +// is this frontend's extension (the product's P0 was not captured): a solid action-coloured mark, +// stronger than the ring, so the more urgent priority never reads as the same mark as P1. P2 is +// plain body text and P3 is muted, so the column quietens as the priority falls. +type PriorityMark { + priority: RoadmapPriority + explanation: String? +} + +type PriorityMarkForm + = PriorityMarkSolid + | PriorityMarkRinged + | PriorityMarkPlain + | PriorityMarkQuiet + +data priority_mark_forms: List = [PriorityMarkSolid, PriorityMarkRinged, PriorityMarkPlain, PriorityMarkQuiet] + +fn priority_mark_form(priority: RoadmapPriority) -> PriorityMarkForm { + match priority { + PriorityP0 => PriorityMarkSolid + PriorityP1 => PriorityMarkRinged + PriorityP2 => PriorityMarkPlain + PriorityP3 => PriorityMarkQuiet + } +} + +fn priority_mark_form_key(form: PriorityMarkForm) -> String { + match form { + PriorityMarkSolid => "solid" + PriorityMarkRinged => "ringed" + PriorityMarkPlain => "plain" + PriorityMarkQuiet => "quiet" + } +} + +// The colours a form changes, and nothing else: the border's colour, the fill and the ink. +fn priority_mark_form_decls(form: PriorityMarkForm) -> List { + match form { + PriorityMarkSolid => [ + decl(prop: BorderColor, value: palette_var(slot: SlotAction)), + decl(prop: Background, value: palette_var(slot: SlotAction)), + decl(prop: Color, value: palette_var(slot: SlotOnAction)), + ] + PriorityMarkRinged => [decl(prop: BorderColor, value: palette_var(slot: SlotAction))] + PriorityMarkPlain => [] + PriorityMarkQuiet => [decl(prop: Color, value: palette_var(slot: SlotTextDim))] + } +} + +data priority_mark_class: String = "el-priority-mark" +data priority_mark_form_attribute: String = "data-priority-form" + +fn priority_mark_title(explanation: String?) -> List { + match explanation { + Present { value: e } => [attr(name: "title", value: e)] + Absent => [] + } +} + +fn priority_mark(input: PriorityMark) -> Fragment { + el_attrs("span", concat([ + attr(name: "class", value: priority_mark_class), + attr(name: priority_mark_form_attribute, value: priority_mark_form_key(form: priority_mark_form(priority: input.priority))), + ], priority_mark_title(explanation: input.explanation)), [txt(roadmap_priority_label(priority: input.priority))]) +} + +// How far a mark's label sits inside its box: the border and the side padding. A heading over a +// column of marks insets its own text by this much. +fn priority_mark_label_inset() -> String { + join(["calc(", scale_var(t: priority_mark_border), " + ", scale_var(t: priority_mark_padding), ")"], "") +} + +fn priority_mark_form_selector(form: PriorityMarkForm) -> String { + join([".", priority_mark_class, "[", priority_mark_form_attribute, "=\"", priority_mark_form_key(form: form), "\"]"], "") +} + +fn priority_mark_rules() -> List { + concat([ + StaticRule { selector: ClassSelector { name: priority_mark_class }, decls: concat([ + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: JustifyContent, value: "flex-start"), + decl(prop: BoxSizing, value: "border-box"), + decl(prop: MinWidth, value: scale_var(t: priority_mark_width)), + decl(prop: Height, value: scale_var(t: height_chip)), + decl(prop: Padding, value: scale_pair(a: space_0, b: priority_mark_padding)), + decl(prop: Border, value: scale_border(w: priority_mark_border, color_var: "transparent")), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + ], element_text_decls(role: ElementBody)) }, + ], flat_map(priority_mark_forms, f => { + let decls = priority_mark_form_decls(form: f) + if count(decls) == 0 { [] } else { [StaticRule { selector: RawSelector { text: priority_mark_form_selector(form: f) }, decls: decls }] } + })) +} diff --git a/dag/gunbc/site/element/relation_table.dag b/dag/gunbc/site/element/relation_table.dag new file mode 100644 index 00000000000..ea2ae65d72b --- /dev/null +++ b/dag/gunbc/site/element/relation_table.dag @@ -0,0 +1,145 @@ +module gunbc.site.element.relation_table + +import std.types { Int, List, String } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap.roadmap_hierarchy_view { + NodeRelation, RelationRow, RelationKind, RelationParent, RelationSubIssues, RelationBlockedBy, RelationBlocking, + RelationRowChildren, RowChildrenNotCarried, RowChildrenExpandable, + RelationCount, RelationCountTotal, RelationCountClosedOfTotal, relation_count, + relation_kind_label, relation_empty_text, relation_undeclared_text, +} +import gunbc.design.material { BuildRule, StaticRule, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_border, space_0, space_8 } +import gunbc.site.element.tokens { height_relation_row, relation_cell_padding, relation_row_line } +import gunbc.site.element.palette { palette_var, SlotTextDim, SlotBorder } +import gunbc.site.element.text { element_text_decls, ElementBody } +import gunbc.site.element.section_heading { SectionHeading, SectionCount, SectionCounted, SectionCountedOfTotal, section_heading } +import gunbc.site.element.chip { Chip, chip } +import gunbc.site.element.node_row { + NodeRow, NodeRowPlace, NodeRowFlat, NodeRowInTree, + NodeRowDensityTable, node_row, node_row_header, node_row_column_count, +} +import extdeps.languages.css.selectors { ClassSelector } +import extdeps.languages.css.properties { + AlignItems, BorderBottom, BorderCollapse, Color, Display, Gap, Height, Margin, Padding, Width, +} + +// RelationTable: one relation of a node (its parent, its sub-issues, what blocks it, what it +// blocks) as a counted heading over a table of NodeRows. It takes the typed relation +// (gunbc.roadmap.roadmap_hierarchy_view NodeRelation) and reads the heading's words, its count and +// the rows from that one value, so a heading cannot count rows the table does not show. A +// relation with nothing in it says so in a sentence; an id the relation names that no declared +// node answers to is shown as a named row that says so, never dropped and never drawn as a node. +// +// `depth` is the level the relation's own rows sit at. The hierarchy view carries no loaded +// sub-issues, so every table drawn today is at depth 0; the parameter is where a table of loaded +// sub-issues of a row will say it is one level down. +type RelationTable { + relation: NodeRelation + depth: Int +} + +data relation_table_class: String = "el-relation-table" +data relation_table_rows_class: String = "el-relation-table-rows" +data relation_table_empty_class: String = "el-relation-table-empty" +data relation_table_undeclared_class: String = "el-relation-table-undeclared" +data relation_table_undeclared_cell_class: String = "el-relation-table-undeclared-cell" +data relation_table_undeclared_note_class: String = "el-relation-table-undeclared-note" +data relation_table_kind_attribute: String = "data-relation-kind" + +fn relation_table_kind_key(kind: RelationKind) -> String { + match kind { + RelationParent => "parent" + RelationSubIssues => "sub-issues" + RelationBlockedBy => "blocked-by" + RelationBlocking => "blocking" + } +} + +fn relation_table_section_count(c: RelationCount) -> SectionCount { + match c { + RelationCountTotal { total } => SectionCounted { n: total } + RelationCountClosedOfTotal { closed, total } => SectionCountedOfTotal { n: closed, total: total } + } +} + +// A row's place is what the view says about its children: a row the view carries no children +// reference for is flat, and a row it does carry one for is a tree row at the table's depth. +fn relation_table_row_place(children: RelationRowChildren, depth: Int) -> NodeRowPlace { + match children { + RowChildrenNotCarried => NodeRowFlat + RowChildrenExpandable { count: _ } => NodeRowInTree { depth: depth, children: children } + } +} + +fn relation_table_undeclared_row(kind: RelationKind, id: String) -> Fragment { + el_attrs("tr", [attr(name: "class", value: relation_table_undeclared_class)], [ + el_attrs("td", [ + attr(name: "class", value: relation_table_undeclared_cell_class), + attr(name: "colspan", value: to_string(node_row_column_count())), + ], [el_attrs("div", [attr(name: "class", value: relation_table_undeclared_note_class)], [ + chip(input: Chip { text: id }), + el_attrs("span", [], [txt(relation_undeclared_text(kind: kind))]), + ])]), + ]) +} + +fn relation_table_body(input: RelationTable) -> List { + let relation = input.relation + if count(relation.rows) == 0 && count(relation.undeclared) == 0 { + [el_attrs("p", [attr(name: "class", value: relation_table_empty_class)], [txt(relation_empty_text(kind: relation.kind))])] + } else { + [ + el_attrs("table", [attr(name: "class", value: relation_table_rows_class)], [ + el_attrs("thead", [], [node_row_header(density: NodeRowDensityTable)]), + el_attrs("tbody", [], concat( + map(relation.rows, row => node_row(input: NodeRow { + node: row.node, + place: relation_table_row_place(children: row.children, depth: input.depth), + density: NodeRowDensityTable, + })), + map(relation.undeclared, id => relation_table_undeclared_row(kind: relation.kind, id: id)))), + ]), + ] + } +} + +fn relation_table(input: RelationTable) -> Fragment { + el_attrs("section", [ + attr(name: "class", value: relation_table_class), + attr(name: relation_table_kind_attribute, value: relation_table_kind_key(kind: input.relation.kind)), + ], concat([ + section_heading(input: SectionHeading { + text: relation_kind_label(kind: input.relation.kind), + count: relation_table_section_count(c: relation_count(relation: input.relation)), + }), + ], relation_table_body(input: input))) +} + +fn relation_table_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: relation_table_class }, decls: [ + decl(prop: Margin, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: relation_table_rows_class }, decls: [ + decl(prop: Width, value: "100%"), + decl(prop: BorderCollapse, value: "collapse"), + ] }, + StaticRule { selector: ClassSelector { name: relation_table_empty_class }, decls: concat(element_text_decls(role: ElementBody), [ + decl(prop: Margin, value: scale_var(t: space_0)), + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ]) }, + StaticRule { selector: ClassSelector { name: relation_table_undeclared_cell_class }, decls: concat(element_text_decls(role: ElementBody), [ + decl(prop: Height, value: scale_var(t: height_relation_row)), + decl(prop: Padding, value: scale_pair(a: space_0, b: relation_cell_padding)), + decl(prop: BorderBottom, value: scale_border(w: relation_row_line, color_var: palette_var(slot: SlotBorder))), + decl(prop: Color, value: palette_var(slot: SlotTextDim)), + ]) }, + StaticRule { selector: ClassSelector { name: relation_table_undeclared_note_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + ] }, + ] +} diff --git a/dag/gunbc/site/element/section_heading.dag b/dag/gunbc/site/element/section_heading.dag new file mode 100644 index 00000000000..9dfd7461974 --- /dev/null +++ b/dag/gunbc/site/element/section_heading.dag @@ -0,0 +1,45 @@ +module gunbc.site.element.section_heading + +import std.types { Int, List, String } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.design.material { BuildRule, StaticRule, decl } +import gunbc.design.scale { scale_pair, space_0, space_8 } +import gunbc.site.element.text { element_text_decls, ElementSectionHeading } +import extdeps.languages.css.selectors { ClassSelector } +import extdeps.languages.css.properties { Margin } + +// SectionHeading: the heading of a section inside a card ("Parent (1)", "Sub-issues (1/3)"). The +// count is a typed input, so the heading formats numbers its caller derived and never counts +// anything itself. +type SectionCount + = SectionUncounted + | SectionCounted { n: Int } + | SectionCountedOfTotal { n: Int, total: Int } + +type SectionHeading { + text: String + count: SectionCount +} + +data section_heading_class: String = "el-section-heading" + +fn section_heading_text(input: SectionHeading) -> String { + match input.count { + SectionUncounted => input.text + SectionCounted { n } => join([input.text, " (", to_string(n), ")"], "") + SectionCountedOfTotal { n, total } => join([input.text, " (", to_string(n), "/", to_string(total), ")"], "") + } +} + +fn section_heading(input: SectionHeading) -> Fragment { + el_attrs("h3", [attr(name: "class", value: section_heading_class)], [txt(section_heading_text(input: input))]) +} + +fn section_heading_rules() -> List { + [ + StaticRule { selector: ClassSelector { name: section_heading_class }, decls: concat([ + decl(prop: Margin, value: scale_pair(a: space_8, b: space_0)), + ], element_text_decls(role: ElementSectionHeading)) }, + ] +} diff --git a/dag/gunbc/site/element/status_chip.dag b/dag/gunbc/site/element/status_chip.dag new file mode 100644 index 00000000000..23f5426a2ca --- /dev/null +++ b/dag/gunbc/site/element/status_chip.dag @@ -0,0 +1,163 @@ +module gunbc.site.element.status_chip + +import std.types { Int, List, String } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.roadmap_issue_status { + IssueDisplayStatus, DisplayReady, DisplayAssigned, DisplayActive, DisplayReview, DisplayBlocked, + DisplayReturned, DisplayDone, DisplaySuperseded, DisplayUnknown, issue_display_status_label, +} +import gunbc.design.material { BuildRule, StaticRule, decl } +import gunbc.design.scale { scale_var, scale_pair, space_0 } +import gunbc.site.element.tokens { radius_element, height_chip, status_chip_padding, status_chip_ring } +import gunbc.site.element.palette { + PaletteSlot, palette_var, SlotTextDim, SlotSubtle, SlotSurface, + SlotStatusReady, SlotStatusAssigned, SlotStatusBlocked, +} +import gunbc.site.element.text { element_text_decls, ElementIdentifier } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, BorderRadius, BoxShadow, Color, CustomProperty, Display, Height, Padding, WhiteSpace, +} + +// StatusChip: the one rendering of an issue's displayed status. It takes the typed status +// (gunbc.roadmap_issue_status) and nothing else, so no caller can hand it a word to classify. +// The geometry is the product's status chip. The vocabulary is ours, mapped onto the product's +// three forms: an ink ring over a light tint of the same ink for the open states, text in the +// surface colour on a muted fill for done, and a muted ring on the subtle ground for superseded +// and unknown. +type StatusChipInk + = StatusChipInkReady + | StatusChipInkAssigned + | StatusChipInkBlocked + +type StatusChipForm + = StatusChipTinted { ink: StatusChipInk } + | StatusChipFilled + | StatusChipQuiet + +fn status_chip_form(status: IssueDisplayStatus) -> StatusChipForm { + match status { + DisplayReady => StatusChipTinted { ink: StatusChipInkReady } + DisplayAssigned => StatusChipTinted { ink: StatusChipInkAssigned } + DisplayActive => StatusChipTinted { ink: StatusChipInkAssigned } + DisplayReview => StatusChipTinted { ink: StatusChipInkAssigned } + DisplayBlocked => StatusChipTinted { ink: StatusChipInkBlocked } + DisplayReturned => StatusChipTinted { ink: StatusChipInkBlocked } + DisplayDone => StatusChipFilled + DisplaySuperseded => StatusChipQuiet + DisplayUnknown => StatusChipQuiet + } +} + +data status_chip_inks: List = [StatusChipInkReady, StatusChipInkAssigned, StatusChipInkBlocked] + +fn status_chip_ink_key(ink: StatusChipInk) -> String { + match ink { + StatusChipInkReady => "ready" + StatusChipInkAssigned => "assigned" + StatusChipInkBlocked => "blocked" + } +} + +fn status_chip_ink_slot(ink: StatusChipInk) -> PaletteSlot { + match ink { + StatusChipInkReady => SlotStatusReady + StatusChipInkAssigned => SlotStatusAssigned + StatusChipInkBlocked => SlotStatusBlocked + } +} + +fn status_chip_form_key(form: StatusChipForm) -> String { + match form { + StatusChipTinted { ink } => status_chip_ink_key(ink: ink) + StatusChipFilled => "filled" + StatusChipQuiet => "quiet" + } +} + +data status_chip_class: String = "el-status-chip" +data status_chip_form_attribute: String = "data-status-form" +data status_chip_ring_property: String = "el-status-chip-ring" +data status_chip_fill_property: String = "el-status-chip-fill" + +// How much of the ink an open state's fill carries, over the surface colour. +data status_chip_tint_pct: Int = 12 + +// WHAT A CHIP SHOWS. A planned node shows its one display status. A node the plan does not carry +// is shown as what its own authority establishes: Accepted when the acceptance authority lists it, +// Withheld when the plan's withheld set names it. Both are quiet forms beside Done and Superseded: +// accepted is settled work and draws as Done does, withheld is set aside and draws as Superseded +// does, each under its own word. +type StatusChipSubject + = StatusChipPlanned { status: IssueDisplayStatus } + | StatusChipAccepted + | StatusChipWithheld + +data status_chip_accepted_label: String = "Accepted" +data status_chip_withheld_label: String = "Withheld" + +fn status_chip_subject_form(subject: StatusChipSubject) -> StatusChipForm { + match subject { + StatusChipPlanned { status } => status_chip_form(status: status) + StatusChipAccepted => StatusChipFilled + StatusChipWithheld => StatusChipQuiet + } +} + +fn status_chip_subject_label(subject: StatusChipSubject) -> String { + match subject { + StatusChipPlanned { status } => issue_display_status_label(status: status) + StatusChipAccepted => status_chip_accepted_label + StatusChipWithheld => status_chip_withheld_label + } +} + +fn status_chip_of(subject: StatusChipSubject) -> Fragment { + el_attrs("span", [ + attr(name: "class", value: status_chip_class), + attr(name: status_chip_form_attribute, value: status_chip_form_key(form: status_chip_subject_form(subject: subject))), + ], [txt(status_chip_subject_label(subject: subject))]) +} + +fn status_chip(status: IssueDisplayStatus) -> Fragment { + status_chip_of(subject: StatusChipPlanned { status: status }) +} + +fn status_chip_form_selector(form: StatusChipForm) -> String { + join([".", status_chip_class, "[", status_chip_form_attribute, "=\"", status_chip_form_key(form: form), "\"]"], "") +} + +fn status_chip_property_var(name: String) -> String { + join(["var(--", name, ")"], "") +} + +fn status_chip_tinted_rule(ink: StatusChipInk) -> BuildRule { + StaticRule { selector: RawSelector { text: status_chip_form_selector(form: StatusChipTinted { ink: ink }) }, decls: [ + decl(prop: CustomProperty { name: status_chip_ring_property }, value: palette_var(slot: status_chip_ink_slot(ink: ink))), + decl(prop: CustomProperty { name: status_chip_fill_property }, value: join([ + "color-mix(in srgb, ", palette_var(slot: status_chip_ink_slot(ink: ink)), " ", to_string(status_chip_tint_pct), "%, ", palette_var(slot: SlotSurface), ")", + ], "")), + ] } +} + +fn status_chip_rules() -> List { + concat([ + StaticRule { selector: ClassSelector { name: status_chip_class }, decls: concat([ + decl(prop: CustomProperty { name: status_chip_ring_property }, value: palette_var(slot: SlotTextDim)), + decl(prop: CustomProperty { name: status_chip_fill_property }, value: palette_var(slot: SlotSubtle)), + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Height, value: scale_var(t: height_chip)), + decl(prop: Padding, value: scale_pair(a: space_0, b: status_chip_padding)), + decl(prop: BorderRadius, value: scale_var(t: radius_element)), + decl(prop: Background, value: status_chip_property_var(name: status_chip_fill_property)), + decl(prop: BoxShadow, value: join(["inset 0 0 0 ", scale_var(t: status_chip_ring), " ", status_chip_property_var(name: status_chip_ring_property)], "")), + decl(prop: WhiteSpace, value: "nowrap"), + ], element_text_decls(role: ElementIdentifier)) }, + StaticRule { selector: RawSelector { text: status_chip_form_selector(form: StatusChipFilled) }, decls: [ + decl(prop: CustomProperty { name: status_chip_fill_property }, value: palette_var(slot: SlotTextDim)), + decl(prop: Color, value: palette_var(slot: SlotSurface)), + ] }, + ], map(status_chip_inks, ink => status_chip_tinted_rule(ink: ink))) +} diff --git a/dag/gunbc/site/element/styles.dag b/dag/gunbc/site/element/styles.dag new file mode 100644 index 00000000000..3d2ac4db373 --- /dev/null +++ b/dag/gunbc/site/element/styles.dag @@ -0,0 +1,79 @@ +module gunbc.site.element.styles + +import std.types { List, String } +import gunbc.design.material { BuildRule } +import gunbc.site.document { SiteThemeDecls } +import gunbc.site.element.palette { palette_decls } +import gunbc.site.element.tokens { element_token_decls } +import gunbc.site.element.card { card_class, card_rules } +import gunbc.site.element.section_heading { section_heading_class, section_heading_rules } +import gunbc.site.element.status_chip { status_chip_class, status_chip_rules } +import gunbc.site.element.button { button_class, button_rules } +import gunbc.site.element.chip { chip_class, chip_rules } +import gunbc.site.element.priority_mark { priority_mark_class, priority_mark_rules } +import gunbc.site.element.node_row { node_row_class, node_row_rules } +import gunbc.site.element.relation_table { relation_table_class, relation_table_rules } +import gunbc.site.element.node_chain { node_chain_class, node_chain_rules } +import gunbc.site.element.build_meter { build_meter_class, build_meter_rules } +import gunbc.site.element.field_list { field_list_class, field_list_rules } + +// What a page needs to use the elements: every element's rules, and the :root blocks they read +// (the palette per scheme, and this frontend's tokens, which do not vary by scheme). Each element +// is one row naming its class and its rules, and the ownership guard reads the same rows. +type ElementStyle { + class_name: String + rules: List +} + +// THE ELEMENTS, AS ONE CLOSED IDENTITY. An element's registry row (element_style, here) and its +// specimen section (gunbc.site.specimen specimen_element_sections) are both total functions of +// it, so an element cannot be registered without a specimen: the specimen's match would refuse +// to compile. site_elements is the one enumeration both are read through. +type SiteElement + = SiteCard + | SiteSectionHeading + | SiteStatusChip + | SiteButton + | SiteChip + | SitePriorityMark + | SiteNodeRow + | SiteRelationTable + | SiteNodeChain + | SiteBuildMeter + | SiteFieldList + +data site_elements: List = [ + SiteCard, SiteSectionHeading, SiteStatusChip, SiteButton, SiteChip, SitePriorityMark, + SiteNodeRow, SiteRelationTable, SiteNodeChain, SiteBuildMeter, SiteFieldList, +] + +fn element_style(element: SiteElement) -> ElementStyle { + match element { + SiteCard => ElementStyle { class_name: card_class, rules: card_rules() } + SiteSectionHeading => ElementStyle { class_name: section_heading_class, rules: section_heading_rules() } + SiteStatusChip => ElementStyle { class_name: status_chip_class, rules: status_chip_rules() } + SiteButton => ElementStyle { class_name: button_class, rules: button_rules() } + SiteChip => ElementStyle { class_name: chip_class, rules: chip_rules() } + SitePriorityMark => ElementStyle { class_name: priority_mark_class, rules: priority_mark_rules() } + SiteNodeRow => ElementStyle { class_name: node_row_class, rules: node_row_rules() } + SiteRelationTable => ElementStyle { class_name: relation_table_class, rules: relation_table_rules() } + SiteNodeChain => ElementStyle { class_name: node_chain_class, rules: node_chain_rules() } + SiteBuildMeter => ElementStyle { class_name: build_meter_class, rules: build_meter_rules() } + SiteFieldList => ElementStyle { class_name: field_list_class, rules: field_list_rules() } + } +} + +fn element_styles() -> List { + map(site_elements, e => element_style(element: e)) +} + +fn element_rules() -> List { + flat_map(element_styles(), s => s.rules) +} + +fn element_themes() -> List { + [ + SiteThemeDecls { day: palette_decls(night: false), night: palette_decls(night: true) }, + SiteThemeDecls { day: element_token_decls(), night: element_token_decls() }, + ] +} diff --git a/dag/gunbc/site/element/text.dag b/dag/gunbc/site/element/text.dag new file mode 100644 index 00000000000..86d96f219f4 --- /dev/null +++ b/dag/gunbc/site/element/text.dag @@ -0,0 +1,102 @@ +module gunbc.site.element.text + +import std.types { List, String } +import extdeps.google.issue_tracker { + IssueTrackerMeasuredText, + IssueTrackerMeasuredInk, IssueTrackerInkText, IssueTrackerInkMuted, IssueTrackerInkAction, IssueTrackerInkCode, + IssueTrackerMeasuredCase, IssueTrackerCaseAuthored, IssueTrackerCaseUppercase, + issue_tracker_measured_type_scale, +} +import gunbc.design.material { CssDecl, decl } +import gunbc.design.scale { scale_var } +import gunbc.site.element.tokens { + face_var, text_size_token, text_leading_token, text_tracking_token, text_tracks, raw_var, +} +import gunbc.site.element.palette { palette_var, palette_action_ink_var, SlotText, SlotTextDim, SlotCodeInk } +import extdeps.languages.css.properties { Color, FontFamily, FontSize, FontWeight, LetterSpacing, LineHeight, TextTransform } + +// The nine text roles of the internal tools frontend. A role adopts one measured row of the +// product's type scale and produces its declarations from tokens, so an element states which +// role its text plays and never a size, weight or face. These are the roles the element modules +// use; gunbc.design.typography InterfaceText still serves the pages that have not migrated. +type ElementText + = ElementBody + | ElementLabel + | ElementCardLabel + | ElementSectionHeading + | ElementControl + | ElementTitle + | ElementProseHeading + | ElementIdentifier + | ElementCode + +data element_text_roles: List = [ + ElementBody, ElementLabel, ElementCardLabel, ElementSectionHeading, ElementControl, + ElementTitle, ElementProseHeading, ElementIdentifier, ElementCode, +] + +fn element_text_name(role: ElementText) -> String { + match role { + ElementBody => "Body" + ElementLabel => "Label" + ElementCardLabel => "CardLabel" + ElementSectionHeading => "SectionHeading" + ElementControl => "Control" + ElementTitle => "Title" + ElementProseHeading => "ProseHeading" + ElementIdentifier => "Identifier" + ElementCode => "Code" + } +} + +fn element_text_measure(role: ElementText) -> IssueTrackerMeasuredText { + match role { + ElementBody => issue_tracker_measured_type_scale.body + ElementLabel => issue_tracker_measured_type_scale.field_label + ElementCardLabel => issue_tracker_measured_type_scale.card_label + ElementSectionHeading => issue_tracker_measured_type_scale.section_heading + ElementControl => issue_tracker_measured_type_scale.control + ElementTitle => issue_tracker_measured_type_scale.title + ElementProseHeading => issue_tracker_measured_type_scale.markdown_heading + ElementIdentifier => issue_tracker_measured_type_scale.chip + ElementCode => issue_tracker_measured_type_scale.inline_code + } +} + +fn measured_ink_var(ink: IssueTrackerMeasuredInk) -> String { + match ink { + IssueTrackerInkText => palette_var(slot: SlotText) + IssueTrackerInkMuted => palette_var(slot: SlotTextDim) + IssueTrackerInkAction => palette_action_ink_var() + IssueTrackerInkCode => palette_var(slot: SlotCodeInk) + } +} + +fn measured_case_value(c: IssueTrackerMeasuredCase) -> String { + match c { + IssueTrackerCaseAuthored => "none" + IssueTrackerCaseUppercase => "uppercase" + } +} + +fn measured_text_decls(m: IssueTrackerMeasuredText) -> List { + [ + decl(prop: FontFamily, value: face_var(face: m.face)), + decl(prop: FontSize, value: scale_var(t: text_size_token(m: m))), + decl(prop: LineHeight, value: scale_var(t: text_leading_token(m: m))), + decl(prop: FontWeight, value: to_string(m.weight)), + decl(prop: Color, value: measured_ink_var(ink: m.ink)), + decl(prop: TextTransform, value: measured_case_value(c: m.letter_case)), + decl(prop: LetterSpacing, value: if text_tracks(m: m) { raw_var(t: text_tracking_token(m: m)) } else { "normal" }), + ] +} + +fn element_text_decls(role: ElementText) -> List { + measured_text_decls(m: element_text_measure(role: role)) +} + +// The label of a small button: the product sets it one size down from the control role, and +// nothing else about it changes. +fn element_control_small_size_decls() -> List { + [decl(prop: FontSize, value: scale_var(t: text_size_token(m: issue_tracker_measured_type_scale.control_small)))] +} diff --git a/dag/gunbc/site/element/tokens.dag b/dag/gunbc/site/element/tokens.dag new file mode 100644 index 00000000000..e5c03b7c07a --- /dev/null +++ b/dag/gunbc/site/element/tokens.dag @@ -0,0 +1,208 @@ +module gunbc.site.element.tokens + +import std.types { Bool, Int, List, String } +import std.list { distinct_by_key } +import std.decimal { fixed_point_wire } +import v2.std.algebra { filter } +import extdeps.google.issue_tracker { + IssueTrackerMeasuredText, + IssueTrackerMeasuredFace, IssueTrackerFaceBody, IssueTrackerFaceHeading, IssueTrackerFaceMonospace, + issue_tracker_measured_type_scale, IssueTrackerMeasuredTypeScale, + issue_tracker_measured_metrics, + issue_tracker_measured_card, + issue_tracker_measured_status_chip, + issue_tracker_measured_button, + issue_tracker_measured_header_chip, + issue_tracker_measured_priority_mark, + issue_tracker_measured_relation_table, + issue_tracker_measured_field_rail, +} +import gunbc.design.material { CssDecl, decl } +import gunbc.design.scale { + ScaleToken, length_token, named_length_token, font_token, scale_var, scale_decl, + all_scale_tokens, font_sans, font_sans_families, height_control, +} +import extdeps.languages.css.values { GenericFamily, GenericMonospace, NamedFamily } +import extdeps.fonts.google_fonts { google_sans_source } +import extdeps.languages.css.properties { CustomProperty } + +// The length and font tokens of the internal tools frontend, each built from the upstream +// measurement it adopts (extdeps.google.issue_tracker) with the constructors of +// gunbc.design.scale. They are scoped to this frontend: the shared scale's :root block is +// emitted on every site page, including pages outside this frontend, so nothing is added to it. +// A value-named token (space-16, text-13, radius-4) is the same token whoever constructs it, so +// the block emitted here is this module's tokens MINUS the ones the shared scale already emits, +// computed by name; a size the shared scale later gains simply stops being emitted here. + +// Corners. One radius for cards, chips, buttons, inputs and status; a smaller one for code. +data radius_element: ScaleToken = length_token(prefix: "radius", px: issue_tracker_measured_metrics.radius_px) +data radius_code: ScaleToken = length_token(prefix: "radius", px: issue_tracker_measured_metrics.code_radius_px) + +// Heights (ruling R3: the product's density on desktop). Two are local adjustments and say so: +// relation rows adopt the measured header height where the product's rows are one px taller, and +// grid rows adopt 36 where the product's are 37. The measurements stay as measured upstream. +data height_chip: ScaleToken = named_length_token(name: "h-chip", px: issue_tracker_measured_metrics.chip_height_px) +data height_button_small: ScaleToken = named_length_token(name: "h-button-small", px: issue_tracker_measured_metrics.button_small_height_px) +data height_button: ScaleToken = named_length_token(name: "h-button", px: issue_tracker_measured_metrics.button_height_px) +data height_tab: ScaleToken = named_length_token(name: "h-tab", px: issue_tracker_measured_metrics.tab_height_px) +data height_relation_row: ScaleToken = named_length_token(name: "h-relation-row", px: issue_tracker_measured_metrics.relation_header_height_px) +data height_grid_row: ScaleToken = named_length_token(name: "h-grid-row", px: 36) +data height_bar: ScaleToken = named_length_token(name: "h-bar", px: issue_tracker_measured_metrics.top_bar_height_px) +data height_card_label_row: ScaleToken = named_length_token(name: "h-card-label-row", px: issue_tracker_measured_card.label_row_height_px) + +// The field list (gunbc.site.element.field_list) and the rail column it sits in. The row pitch, +// the label column's clamp and gap, the rail's width, divider and inset are the product's +// sidebar as measured. One is a local adjustment and says so: the product draws a 12px avatar +// before a name, where the initials this frontend draws inside the circle do not read; the inline +// avatar takes the measured value line height instead, so it is as tall as the text beside it. +data height_field_row: ScaleToken = named_length_token(name: "h-field-row", px: issue_tracker_measured_field_rail.row_pitch_px) +data field_label_min: ScaleToken = named_length_token(name: "w-field-label-min", px: issue_tracker_measured_field_rail.label_column_min_px) +data field_label_max: ScaleToken = named_length_token(name: "w-field-label-max", px: issue_tracker_measured_field_rail.label_column_max_px) +data field_label_column_pct: Int = issue_tracker_measured_field_rail.label_column_pct +data field_label_gap: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_field_rail.label_gap_px) +data field_avatar: ScaleToken = named_length_token(name: "h-field-avatar", px: issue_tracker_measured_field_rail.value_line_height_px) +data field_rail_width: ScaleToken = named_length_token(name: "w-field-rail", px: issue_tracker_measured_field_rail.width_px) +data field_rail_divider: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_field_rail.divider_px) +data field_rail_inset: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_field_rail.side_inset_px) + +// One row per adopted height beside the measurement it was adopted from, for the specimen's +// density table. The touch target is the shared h-control token (ruling R3: 40px under a coarse +// pointer); it has no upstream measurement because the product does not reflow for touch. +type ElementHeightRow { + label: String + token: ScaleToken + measured_px: Int? +} + +fn element_height_rows() -> List { + [ + ElementHeightRow { label: "Chip", token: height_chip, measured_px: Present { value: issue_tracker_measured_metrics.chip_height_px } }, + ElementHeightRow { label: "Button, small", token: height_button_small, measured_px: Present { value: issue_tracker_measured_metrics.button_small_height_px } }, + ElementHeightRow { label: "Button", token: height_button, measured_px: Present { value: issue_tracker_measured_metrics.button_height_px } }, + ElementHeightRow { label: "Tab", token: height_tab, measured_px: Present { value: issue_tracker_measured_metrics.tab_height_px } }, + ElementHeightRow { label: "Relation row", token: height_relation_row, measured_px: Present { value: issue_tracker_measured_metrics.relation_row_height_px } }, + ElementHeightRow { label: "Grid row", token: height_grid_row, measured_px: Present { value: issue_tracker_measured_metrics.grid_row_height_px } }, + ElementHeightRow { label: "Card label row", token: height_card_label_row, measured_px: Present { value: issue_tracker_measured_card.label_row_height_px } }, + ElementHeightRow { label: "Top bar", token: height_bar, measured_px: Present { value: issue_tracker_measured_metrics.top_bar_height_px } }, + ElementHeightRow { label: "Field row", token: height_field_row, measured_px: Present { value: issue_tracker_measured_field_rail.row_pitch_px } }, + ElementHeightRow { label: "Inline avatar", token: field_avatar, measured_px: Present { value: issue_tracker_measured_field_rail.inline_avatar_px } }, + ElementHeightRow { label: "Touch target", token: height_control, measured_px: Absent }, + ] +} + +// Card, status chip and button geometry. +data card_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_card.padding_px) +data card_outer_margin: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_card.outer_margin_px) +data card_border: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_card.border_px) +data card_leading_bar: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_card.leading_bar_px) +data status_chip_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_status_chip.side_padding_px) +data status_chip_ring: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_status_chip.ring_px) +data button_small_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_button.small_side_padding_px) +data button_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_button.side_padding_px) +data button_border: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_button.border_px) + +// Header chip, priority mark and relation table geometry. +data chip_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_header_chip.side_padding_px) +data priority_mark_width: ScaleToken = named_length_token(name: "w-priority-mark", px: issue_tracker_measured_priority_mark.width_px) +data priority_mark_border: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_priority_mark.border_px) +data priority_mark_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_priority_mark.side_padding_px) +data relation_section_gap: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_relation_table.section_gap_px) +data relation_cell_padding: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_relation_table.cell_side_padding_px) +data relation_row_line: ScaleToken = length_token(prefix: "border", px: issue_tracker_measured_relation_table.row_line_px) +data relation_level_indent: ScaleToken = length_token(prefix: "space", px: issue_tracker_measured_relation_table.level_indent_px) + +// A custom property whose value is not a typed length or font stack: a fractional letter spacing. +type RawToken { + name: String + value: String +} + +fn raw_var(t: RawToken) -> String { + join(["var(--", t.name, ")"], "") +} + +fn raw_decl(t: RawToken) -> CssDecl { + decl(prop: CustomProperty { name: t.name }, value: t.value) +} + +// Faces (ruling R2). Body is the shared sans token: the self-hosted Roboto face ahead of the +// product's fallbacks. The heading stack is the self-hosted Google Sans face ahead of that same +// body stack, which is the product's measured heading stack (issue_tracker_visual_tokens +// font_heading); each family name is the upstream source row's that the served @font-face rule +// names (extdeps.fonts.google_fonts, served by gunbc.site.web_fonts), so no family is spelled +// here. Code is the generic monospace family (R2: no monospace file is shipped). +data font_heading: ScaleToken = font_token(name: "font-heading", families: concat([NamedFamily { name: google_sans_source.family }], font_sans_families)) +data font_code: ScaleToken = font_token(name: "font-code", families: [GenericFamily { kind: GenericMonospace }]) + +fn face_var(face: IssueTrackerMeasuredFace) -> String { + match face { + IssueTrackerFaceBody => scale_var(t: font_sans) + IssueTrackerFaceHeading => scale_var(t: font_heading) + IssueTrackerFaceMonospace => scale_var(t: font_code) + } +} + +// Type. A measured row's size is adopted in whole CSS px (ruling: sizes 11, 12, 13, 14, 18), so +// the markdown heading's measured 18.2 is adopted as 18; every other row is already whole. +fn text_size_token(m: IssueTrackerMeasuredText) -> ScaleToken { + length_token(prefix: "text", px: m.size_tenths_px / 10) +} + +fn text_leading_token(m: IssueTrackerMeasuredText) -> ScaleToken { + length_token(prefix: "leading", px: m.line_height_px) +} + +fn text_tracking_token(m: IssueTrackerMeasuredText) -> RawToken { + RawToken { + name: join(["tracking-", to_string(m.tracking_hundredths_px)], ""), + value: join([fixed_point_wire(value: m.tracking_hundredths_px, decimals: 2), "px"], ""), + } +} + +fn text_tracks(m: IssueTrackerMeasuredText) -> Bool { + m.tracking_hundredths_px != 0 +} + +// Every measured type row, so that whichever row a text role adopts has its tokens emitted. +fn measured_text_rows(s: IssueTrackerMeasuredTypeScale) -> List { + [ + s.body, s.field_label, s.card_label, s.section_heading, s.control, s.control_small, + s.title, s.markdown_heading, s.chip, s.inline_code, + ] +} + +fn element_length_tokens() -> List { + concat([ + radius_element, radius_code, + height_chip, height_button_small, height_button, height_tab, height_relation_row, + height_grid_row, height_bar, height_card_label_row, + card_padding, card_outer_margin, card_border, card_leading_bar, + status_chip_padding, status_chip_ring, + button_small_padding, button_padding, button_border, + chip_padding, priority_mark_width, priority_mark_border, priority_mark_padding, + relation_section_gap, relation_cell_padding, relation_row_line, relation_level_indent, + height_field_row, field_label_min, field_label_max, field_label_gap, field_avatar, + field_rail_width, field_rail_divider, field_rail_inset, + font_heading, font_code, + ], flat_map(measured_text_rows(s: issue_tracker_measured_type_scale), m => [ + text_size_token(m: m), text_leading_token(m: m), + ])) +} + +fn element_raw_tokens() -> List { + map( + filter(measured_text_rows(s: issue_tracker_measured_type_scale), m => text_tracks(m: m)), + m => text_tracking_token(m: m)) +} + +fn token_in_shared_scale(t: ScaleToken) -> Bool { + fold(all_scale_tokens(), init: false, f: (acc, shared) => acc || shared.name == t.name) +} + +// The block this frontend adds to :root: its tokens that the shared scale does not already emit, +// each name once. +fn element_token_decls() -> List { + concat( + map(distinct_by_key(xs: filter(element_length_tokens(), t => !token_in_shared_scale(t: t)), key: t => t.name), t => scale_decl(t: t)), + map(distinct_by_key(xs: element_raw_tokens(), key: t => t.name), t => raw_decl(t: t))) +} diff --git a/dag/gunbc/site/element/touch_target.dag b/dag/gunbc/site/element/touch_target.dag new file mode 100644 index 00000000000..00131884324 --- /dev/null +++ b/dag/gunbc/site/element/touch_target.dag @@ -0,0 +1,61 @@ +module gunbc.site.element.touch_target + +import std.types { List } +import gunbc.design.material { BuildRule, MediaRule, CssDecl, decl } +import gunbc.design.scale { scale_var, height_control, space_0 } +import extdeps.languages.css.media_queries { coarse_pointer } +import extdeps.languages.css.selectors { SelectorSubject } +import extdeps.languages.css.properties { + AlignItems, Display, LineHeight, MaxWidth, MinHeight, MinWidth, Overflow, TextOverflow, VerticalAlign, RawProperty, +} + +// TOUCH TARGETS (ruling R3; element spec 5c: visual height and pointer target are separate). On a +// fine pointer every element keeps the product's density exactly. Under a coarse pointer every +// interactive control is at least the shared touch target, gunbc.design.scale height_control, in +// both directions. The condition is the typed coarse_pointer MediaCondition, so no rule a fine +// pointer reads changes, and an element that is not interactive (a chip, a status, a priority) takes +// no rule at all. +// +// How a control reaches the target depends on how its element lays it out, so the element names the +// shape and the minimum is never given to a box that would ignore it. A box (block, flex or +// inline-flex, centring its own content) takes the minimum as its size. A link laid out as a flex +// item or a block takes the minimum and centres its text in it. A link inside a line of text that is +// cut with an ellipsis becomes an inline block one target tall, still cut with an ellipsis inside +// itself. +type TouchTargetShape + = TouchTargetBox + | TouchTargetCentredLink + | TouchTargetInlineLink + +fn touch_target_minimum_decls() -> List { + [ + decl(prop: MinHeight, value: scale_var(t: height_control)), + decl(prop: MinWidth, value: scale_var(t: height_control)), + ] +} + +fn touch_target_decls(shape: TouchTargetShape) -> List { + match shape { + TouchTargetBox => touch_target_minimum_decls() + TouchTargetCentredLink => concat([ + decl(prop: Display, value: "inline-flex"), + decl(prop: AlignItems, value: "center"), + ], touch_target_minimum_decls()) + TouchTargetInlineLink => concat([ + decl(prop: Display, value: "inline-block"), + decl(prop: VerticalAlign, value: "middle"), + decl(prop: MaxWidth, value: "100%"), + decl(prop: LineHeight, value: scale_var(t: height_control)), + decl(prop: RawProperty { name: "padding-block" }, value: scale_var(t: space_0)), + decl(prop: Overflow, value: "hidden"), + decl(prop: TextOverflow, value: "ellipsis"), + ], touch_target_minimum_decls()) + } +} + +// The one rule a control needs. An element whose control also has to give up a fine-pointer +// spacing under the same condition writes one MediaRule with touch_target_decls and that spacing, +// because a selector is owned once per condition. +fn touch_target_rule(selector: SelectorSubject, shape: TouchTargetShape) -> BuildRule { + MediaRule { condition: coarse_pointer, selector: selector, decls: touch_target_decls(shape: shape) } +} diff --git a/dag/gunbc/site/robot_mark.dag b/dag/gunbc/site/robot_mark.dag index 86277bac03f..dbc5f43f95c 100644 --- a/dag/gunbc/site/robot_mark.dag +++ b/dag/gunbc/site/robot_mark.dag @@ -1,6 +1,7 @@ module gunbc.site.robot_mark -import std.types { String } +import std.types { Int, String } +import std.decimal { fixed_point_wire } import std.markup { Fragment, Element, Attribute, attr } import extdeps.color.srgb { Srgb8 } import gunbc.design.palette { Swatch } @@ -22,11 +23,76 @@ data robot_mark_ink: Swatch = Swatch { name: "robot-mark-ink", color: Srgb8 { r: data robot_mark_view_box: String = "0 0 120 120" -data robot_mark_body_path: String = "M40,20 H80 A30,30 0 0,1 110,50 V70 A30,30 0 0,1 80,100 H40 A30,30 0 0,1 10,70 V50 A30,30 0 0,1 40,20 Z M30,41 H40 A2,2 0 0,1 42,43 V53 A2,2 0 0,1 40,55 H30 A2,2 0 0,1 28,53 V43 A2,2 0 0,1 30,41 Z M80,41 H90 A2,2 0 0,1 92,43 V53 A2,2 0 0,1 90,55 H80 A2,2 0 0,1 78,53 V43 A2,2 0 0,1 80,41 Z M54,54 H66 A2,2 0 0,1 68,56 V58 A2,2 0 0,1 66,60 H54 A2,2 0 0,1 52,58 V56 A2,2 0 0,1 54,54 Z" +// THE MARK'S GEOMETRY AS NUMBERS. The symbol is a squircle body with two square eyes and a flat +// mouth cut out of it (each a rounded rectangle), drawn under one transform, and an antenna of a +// stem and a ball drawn without it. The numbers below are the symbol's own; the path strings are +// derived from them, byte for byte the symbol's paths, so anything that draws the mark at another +// size (gunbc.site.element.build_meter's worker) reads the same numbers instead of re-measuring a +// path string. +type RobotMarkBox { + x: Int + y: Int + w: Int + h: Int + r: Int +} + +type RobotMarkCircle { + cx: Int + cy: Int + r: Int +} + +data robot_mark_body: RobotMarkBox = RobotMarkBox { x: 10, y: 20, w: 100, h: 80, r: 30 } +data robot_mark_left_eye: RobotMarkBox = RobotMarkBox { x: 28, y: 41, w: 14, h: 14, r: 2 } +data robot_mark_right_eye: RobotMarkBox = RobotMarkBox { x: 78, y: 41, w: 14, h: 14, r: 2 } +data robot_mark_mouth: RobotMarkBox = RobotMarkBox { x: 52, y: 54, w: 16, h: 6, r: 2 } +data robot_mark_antenna_stem: RobotMarkBox = RobotMarkBox { x: 58, y: 6, w: 4, h: 6, r: 0 } +data robot_mark_antenna_ball: RobotMarkCircle = RobotMarkCircle { cx: 60, cy: 5, r: 5 } + +// The body group's transform: a uniform scale in tenths, then a translation of both axes. +data robot_mark_body_scale_tenths: Int = 12 +data robot_mark_body_offset: Int = -12 + +fn robot_mark_n(v: Int) -> String { + to_string(v) +} + +fn robot_mark_arc(r: Int, x: Int, y: Int) -> String { + join(["A", robot_mark_n(v: r), ",", robot_mark_n(v: r), " 0 0,1 ", robot_mark_n(v: x), ",", robot_mark_n(v: y)], "") +} + +fn robot_mark_rounded_box_path(b: RobotMarkBox) -> String { + join([ + "M", robot_mark_n(v: b.x + b.r), ",", robot_mark_n(v: b.y), + " H", robot_mark_n(v: b.x + b.w - b.r), + " ", robot_mark_arc(r: b.r, x: b.x + b.w, y: b.y + b.r), + " V", robot_mark_n(v: b.y + b.h - b.r), + " ", robot_mark_arc(r: b.r, x: b.x + b.w - b.r, y: b.y + b.h), + " H", robot_mark_n(v: b.x + b.r), + " ", robot_mark_arc(r: b.r, x: b.x, y: b.y + b.h - b.r), + " V", robot_mark_n(v: b.y + b.r), + " ", robot_mark_arc(r: b.r, x: b.x + b.r, y: b.y), + " Z", + ], "") +} + +fn robot_mark_square_box_path(b: RobotMarkBox) -> String { + join(["M", robot_mark_n(v: b.x), ",", robot_mark_n(v: b.y), " H", robot_mark_n(v: b.x + b.w), " V", robot_mark_n(v: b.y + b.h), " H", robot_mark_n(v: b.x), " Z"], "") +} + +fn robot_mark_circle_path(c: RobotMarkCircle) -> String { + join(["M", robot_mark_n(v: c.cx - c.r), ",", robot_mark_n(v: c.cy), " ", robot_mark_arc(r: c.r, x: c.cx + c.r, y: c.cy), " ", robot_mark_arc(r: c.r, x: c.cx - c.r, y: c.cy), " Z"], "") +} + +data robot_mark_body_path: String = join(map([robot_mark_body, robot_mark_left_eye, robot_mark_right_eye, robot_mark_mouth], b => robot_mark_rounded_box_path(b: b)), " ") -data robot_mark_body_transform: String = "translate(-12 -12) scale(1.2)" +data robot_mark_body_transform: String = join([ + "translate(", robot_mark_n(v: robot_mark_body_offset), " ", robot_mark_n(v: robot_mark_body_offset), ") scale(", + fixed_point_wire(value: robot_mark_body_scale_tenths, decimals: 1), ")", +], "") -data robot_mark_antenna_path: String = "M58,6 H62 V12 H58 Z M55,5 A5,5 0 0,1 65,5 A5,5 0 0,1 55,5 Z" +data robot_mark_antenna_path: String = join([robot_mark_square_box_path(b: robot_mark_antenna_stem), robot_mark_circle_path(c: robot_mark_antenna_ball)], " ") fn robot_mark_svg(class_name: String, label: String) -> Fragment { Element { diff --git a/dag/gunbc/site/specimen.dag b/dag/gunbc/site/specimen.dag new file mode 100644 index 00000000000..bbc4e1c6f97 --- /dev/null +++ b/dag/gunbc/site/specimen.dag @@ -0,0 +1,971 @@ +module gunbc.site.specimen + +import std.types { Int, List, String, NonEmptyStr } +import v2.std.algebra { filter } +import std.markup { Fragment, attr } +import gunbc.site.markup { el_attrs, txt } +import gunbc.site.document { SiteStyles, site_document, site_styles, site_extend_styles } +import extdeps.google.issue_tracker { + IssueTrackerVisualCapture, issue_tracker_visual_capture, issue_tracker_interface_version, + IssueActionButtonIdiom, IssueButtonContainedPrimary, IssueButtonOutlined, IssueButtonText, + IssueActionAvailability, IssueActionEnabled, IssueActionDisabled, +} +import gunbc.roadmap_issue_status { + issue_display_statuses, DisplayReady, DisplayActive, DisplayReview, DisplayBlocked, DisplayDone, DisplaySuperseded, +} +import gunbc.roadmap_priority { RoadmapPriority, DerivedPriority, PriorityP0, PriorityP1, PriorityP2, PriorityP3 } +import gunbc.roadmap_issue_location { issue_page_path } +import gunbc.roadmap.roadmap_lineage { LineageNoParent } +import gunbc.roadmap.roadmap_hierarchy_view { + NodePresentation, NodeStatus, NodeStatusDerived, NodeStatusUnavailable, node_status_derived, + NodeChain, NodeChainRooted, NodeChainRefused, + NodeRelation, RelationRow, RelationParent, RelationSubIssues, RelationBlockedBy, RelationBlocking, + RowChildrenNotCarried, RowChildrenExpandable, +} +import gunbc.design.material { BuildRule, StaticRule, CssDecl, decl } +import gunbc.design.scale { scale_var, scale_pair, scale_value_wire, space_0, space_2, space_8 } +import gunbc.site.element.tokens { ElementHeightRow, element_height_rows, card_outer_margin, card_border, radius_code, relation_section_gap, field_rail_width } +import gunbc.site.element.palette { palette_var, SlotGround, SlotSubtle, SlotBorder } +import gunbc.site.element.text { + ElementText, element_text_roles, element_text_name, element_text_decls, + ElementBody, ElementLabel, ElementCardLabel, ElementSectionHeading, ElementControl, ElementTitle, + ElementProseHeading, ElementIdentifier, ElementCode, +} +import gunbc.site.element.styles { + element_rules, element_themes, SiteElement, site_elements, + SiteCard, SiteSectionHeading, SiteStatusChip, SiteButton, SiteChip, SitePriorityMark, + SiteNodeRow, SiteRelationTable, SiteNodeChain, SiteBuildMeter, SiteFieldList, +} +import gunbc.site.element.card { Card, CardLabelRow, CardEdgePlain, CardEdgeBar, card } +import gunbc.site.element.section_heading { + SectionHeading, SectionUncounted, SectionCounted, SectionCountedOfTotal, section_heading, +} +import gunbc.site.element.status_chip { status_chip } +import gunbc.site.element.button { Button, ButtonSize, ButtonSmall, ButtonRegular, button } +import gunbc.site.element.chip { Chip, chip } +import gunbc.site.element.priority_mark { PriorityMark, priority_mark } +import gunbc.site.element.node_row { NodeRow, NodeRowFlat, NodeRowDensityGrid, node_row, node_row_header } +import gunbc.site.element.relation_table { RelationTable, relation_table } +import gunbc.site.element.node_chain { node_chain } +import gunbc.site.element.field_list { + FieldList, FieldRow, FieldText, FieldLinks, FieldLink, FieldAction, FieldIdentity, + FieldStatusControl, FieldIdentityControl, FieldWork, FieldAbsent, FieldPerson, field_list, +} +import gunbc.site.element.build_meter { + BuildMeterView, build_meter, +} +import gunbc.roadmap.roadmap_build_meter { build_meter_view_of, compact_work_summary_of_view, BuildMeterToolCount, BuildMeterRoundOperations } +import gunbc.harness.harness_tool { RunCommandTool, ReadFileTool, WriteFileTool, EditFileTool } +import gunbc.harness.harness_throughput { HarnessRoundLedger, RoundObservation, RoundsObserved, RoundsUnobserved } +import gunbc.roadmap_provider_events { ProviderExecutionState, ProviderRunning, ProviderAwaitingSeat, ProviderCompleted, ProviderNotStarted, ProviderBudgetSpent, ProviderEventsRefused, ProviderFailed } +import gunbc.site.build_meter_client { build_meter_dwell_ms } +import gunbc.site.specimen_meter_client { specimen_hand_attribute, specimen_flap_attribute, specimen_target_attribute, specimen_flap_ms } +import gunbc.roadmap_workflow_stage { + WorkflowSegment, WorkflowSegmentKind, WorkflowSegmentState, WorkflowSegmentPending, WorkflowSegmentActive, WorkflowSegmentComplete, + WorkflowSegmentFailed, WorkflowSegmentRefused, WorkflowSegmentStopped, + EnvironmentWorkflowSegment, WorkspaceWorkflowSegment, AgentWorkflowSegment, SubmissionWorkflowSegment, + VerificationWorkflowSegment, PublicationWorkflowSegment, +} +import gunbc.roadmap.roadmap_submission { CandidateHandoffState, HandoffAwaitingWorker, HandoffVerificationFailed, HandoffPublished } +import extdeps.languages.css.selectors { ClassSelector, RawSelector } +import extdeps.languages.css.properties { + AlignItems, Background, BorderCollapse, BorderRadius, BoxShadow, Display, FlexDirection, FlexWrap, Gap, GridTemplateColumns, + Height, Margin, MaxWidth, MinHeight, MinWidth, Padding, Width, RawProperty, +} + +// The specimen: every element of the internal tools frontend in every variant, built only from +// the production element functions, on one page. It is where an element is looked at before a +// page adopts it, and it is the first consumer of the foundations (the measured upstream facts, +// the palette, the tokens, the text roles). The classes declared here place and caption the +// specimens; none of them reaches inside an element. +data specimen_route_path: String = "/specimen" + +data specimen_page_class: String = "specimen-page" +data specimen_row_class: String = "specimen-row" +data specimen_note_class: String = "specimen-note" +data specimen_role_class: String = "specimen-role" +data specimen_text_class: String = "specimen-text" +data specimen_text_role_attribute: String = "data-text-role" +data specimen_height_class: String = "specimen-height" +data specimen_height_attribute: String = "data-height" + +fn specimen_row(children: List) -> Fragment { + el_attrs("div", [attr(name: "class", value: specimen_row_class)], children) +} + +fn specimen_note(text: String) -> Fragment { + el_attrs("p", [attr(name: "class", value: specimen_note_class)], [txt(text)]) +} + +fn specimen_section(label: String, body: List) -> Fragment { + card(input: Card { + edge: CardEdgePlain, + label_row: Present { value: CardLabelRow { label: label, trailing: [] } }, + body: body, + }) +} + +fn specimen_source_text(c: IssueTrackerVisualCapture) -> String { + join([ + "Measured on ", c.origin.uri.locator as String, ", ", c.session as String, ", at ", + to_string(c.viewport_width_px), " by ", to_string(c.viewport_height_px), + " CSS px, captured ", c.captured_on as String, ". Captures: ", c.local_copy.uri.locator as String, + "; shared copy: https://", c.shared_copy.uri.locator as String, ". Upstream model version ", + issue_tracker_interface_version as String, ".", + ], "") +} + +fn specimen_source_section() -> Fragment { + specimen_section(label: "Source", body: [ + el_attrs("p", [], [txt(specimen_source_text(c: issue_tracker_visual_capture))]), + ]) +} + +fn specimen_text_row(role: ElementText) -> Fragment { + el_attrs("div", [attr(name: "class", value: specimen_role_class)], [ + specimen_note(text: element_text_name(role: role)), + el_attrs("span", [ + attr(name: "class", value: specimen_text_class), + attr(name: specimen_text_role_attribute, value: element_text_name(role: role)), + ], [txt("Issue tracker specimen text 0123456789")]), + ]) +} + +fn specimen_text_section() -> Fragment { + specimen_section(label: "Text roles", body: map(element_text_roles, r => specimen_text_row(role: r))) +} + +fn specimen_status_section() -> Fragment { + specimen_section(label: "Status chip", body: [ + specimen_row(children: map(issue_display_statuses, s => status_chip(status: s))), + ]) +} + +data specimen_button_idioms: List = [IssueButtonContainedPrimary, IssueButtonOutlined, IssueButtonText] + +fn specimen_button_label(idiom: IssueActionButtonIdiom) -> String { + match idiom { + IssueButtonContainedPrimary => "Filled" + IssueButtonOutlined => "Outlined" + IssueButtonText => "Text" + } +} + +fn specimen_button_row(size: ButtonSize, availability: IssueActionAvailability) -> Fragment { + specimen_row(children: map(specimen_button_idioms, i => button(input: Button { + label: specimen_button_label(idiom: i), + idiom: i, + size: size, + availability: availability, + }))) +} + +fn specimen_button_section() -> Fragment { + specimen_section(label: "Button", body: [ + specimen_note(text: "Regular"), + specimen_button_row(size: ButtonRegular, availability: IssueActionEnabled), + specimen_note(text: "Regular, unavailable"), + specimen_button_row(size: ButtonRegular, availability: IssueActionDisabled), + specimen_note(text: "Small"), + specimen_button_row(size: ButtonSmall, availability: IssueActionEnabled), + specimen_note(text: "Small, unavailable"), + specimen_button_row(size: ButtonSmall, availability: IssueActionDisabled), + ]) +} + +fn specimen_heading_section() -> Fragment { + specimen_section(label: "Section heading", body: [ + section_heading(input: SectionHeading { text: "Acceptance criteria", count: SectionUncounted }), + section_heading(input: SectionHeading { text: "Parent", count: SectionCounted { n: 1 } }), + section_heading(input: SectionHeading { text: "Sub-issues", count: SectionCountedOfTotal { n: 1, total: 3 } }), + ]) +} + +fn specimen_card_body(text: String) -> List { + [el_attrs("p", [], [txt(text)])] +} + +fn specimen_card_section() -> Fragment { + specimen_section(label: "Card", body: [ + specimen_row(children: [ + card(input: Card { edge: CardEdgePlain, label_row: Absent, body: specimen_card_body(text: "A plain card.") }), + card(input: Card { edge: CardEdgeBar, label_row: Absent, body: specimen_card_body(text: "A card with a leading bar.") }), + card(input: Card { + edge: CardEdgePlain, + label_row: Present { value: CardLabelRow { label: "Description", trailing: [] } }, + body: specimen_card_body(text: "A card with a label row."), + }), + card(input: Card { + edge: CardEdgeBar, + label_row: Present { value: CardLabelRow { label: "Status update", trailing: [ + button(input: Button { label: "More details", idiom: IssueButtonText, size: ButtonSmall, availability: IssueActionEnabled }), + ] } }, + body: specimen_card_body(text: "A card with a leading bar, a label row and a trailing control."), + }), + ]), + ]) +} + +fn specimen_measured_text(measured_px: Int?) -> String { + match measured_px { + Present { value: px } => join([to_string(px), "px measured"], "") + Absent => "no upstream measurement" + } +} + +fn specimen_height_text(row: ElementHeightRow) -> String { + join([row.label, ": ", scale_value_wire(v: row.token.value), " adopted, ", specimen_measured_text(measured_px: row.measured_px)], "") +} + +fn specimen_height_row(row: ElementHeightRow) -> Fragment { + el_attrs("div", [ + attr(name: "class", value: specimen_height_class), + attr(name: specimen_height_attribute, value: row.token.name), + ], [txt(specimen_height_text(row: row))]) +} + +fn specimen_height_section() -> Fragment { + specimen_section(label: "Heights", body: map(element_height_rows(), r => specimen_height_row(row: r))) +} + +// The composite: one card with a leading bar and a label row carrying a trailing control, holding +// a section heading, a chip for every status and a row of buttons. +fn specimen_composite() -> Fragment { + card(input: Card { + edge: CardEdgeBar, + label_row: Present { value: CardLabelRow { label: "Composite", trailing: [ + button(input: Button { label: "More details", idiom: IssueButtonText, size: ButtonSmall, availability: IssueActionEnabled }), + ] } }, + body: [ + section_heading(input: SectionHeading { text: "Sub-issues", count: SectionCountedOfTotal { n: 1, total: 3 } }), + specimen_row(children: map(issue_display_statuses, s => status_chip(status: s))), + specimen_row(children: [ + button(input: Button { label: "Add", idiom: IssueButtonContainedPrimary, size: ButtonSmall, availability: IssueActionEnabled }), + button(input: Button { label: "Add parent", idiom: IssueButtonOutlined, size: ButtonSmall, availability: IssueActionEnabled }), + button(input: Button { label: "Display 3 more", idiom: IssueButtonText, size: ButtonSmall, availability: IssueActionEnabled }), + ]), + ], + }) +} + +// THE HIERARCHY FIXTURE. Every node below is planted here; the specimen reads no declared roadmap +// row. Routes come from the one route construction, so a fixture link has the shape a real one has. +data specimen_issue_header_class: String = "specimen-issue-header" +data specimen_issue_title_class: String = "specimen-issue-title" +data specimen_relations_class: String = "specimen-relations" +data specimen_rows_class: String = "specimen-rows" + +fn specimen_node(slug: String, title: String, status: NodeStatus, priority: RoadmapPriority) -> NodePresentation { + NodePresentation { + slug: slug, + title: title, + status: status, + priority: DerivedPriority { priority: priority, source: "", reason: "specimen fixture" }, + route: issue_page_path(node_id: slug), + } +} + +fn specimen_root() -> NodePresentation { + specimen_node(slug: "gunbc-project-root", title: "gunbc: one substrate, every scale", status: node_status_derived(status: DisplayActive), priority: PriorityP1) +} + +fn specimen_project() -> NodePresentation { + specimen_node(slug: "dp-m2-shell-residue-retired", title: "Retire the shell residue behind typed invocations", status: node_status_derived(status: DisplayActive), priority: PriorityP1) +} + +fn specimen_parent() -> NodePresentation { + specimen_node(slug: "shell-typed-invocation", title: "Every shell call is a typed invocation", status: node_status_derived(status: DisplayActive), priority: PriorityP1) +} + +fn specimen_self() -> NodePresentation { + specimen_node(slug: "invocation-argv-model", title: "Model the argv of an invocation as typed parts", status: node_status_derived(status: DisplayBlocked), priority: PriorityP2) +} + +// One node that is both a sub-issue of the fixture issue and one of its blockers: it is the same +// presentation under both relations. +fn specimen_shared() -> NodePresentation { + specimen_node(slug: "argv-quoting-rules", title: "State the quoting rules an argv part obeys", status: node_status_derived(status: DisplayBlocked), priority: PriorityP2) +} + +fn specimen_chain_short() -> NodeChain { + NodeChainRooted { ancestors: [specimen_root(), specimen_project(), specimen_parent()], self_node: specimen_self() } +} + +fn specimen_chain_long() -> NodeChain { + NodeChainRooted { + ancestors: [ + specimen_root(), + specimen_node(slug: "debt-payoff", title: "Pay off the standing debt", status: node_status_derived(status: DisplayActive), priority: PriorityP1), + specimen_project(), + specimen_node(slug: "namespace-unique-on-chain", title: "A name is unique along its chain", status: node_status_derived(status: DisplayReview), priority: PriorityP2), + specimen_node(slug: "typed-effects", title: "Effects are declared, not discovered", status: node_status_derived(status: DisplayReady), priority: PriorityP2), + specimen_parent(), + ], + self_node: specimen_self(), + } +} + +fn specimen_chain_refused() -> NodeChain { + NodeChainRefused { refusal: LineageNoParent { walked: ["invocation-argv-model", "orphaned-arc"], at: "orphaned-arc" } } +} + +fn specimen_relation_parent() -> NodeRelation { + NodeRelation { kind: RelationParent, rows: [RelationRow { node: specimen_parent(), children: RowChildrenNotCarried }], undeclared: [] } +} + +fn specimen_relation_blocked_by() -> NodeRelation { + NodeRelation { + kind: RelationBlockedBy, + rows: [ + RelationRow { node: specimen_shared(), children: RowChildrenNotCarried }, + RelationRow { node: specimen_node(slug: "process-spawn-effect", title: "Spawning a process is a declared effect", status: node_status_derived(status: DisplayDone), priority: PriorityP3), children: RowChildrenNotCarried }, + ], + undeclared: ["retired-shell-probe"], + } +} + +fn specimen_relation_sub_issues() -> NodeRelation { + NodeRelation { + kind: RelationSubIssues, + rows: [ + RelationRow { node: specimen_node(slug: "argv-part-vocabulary", title: "Name the closed vocabulary of argv parts", status: node_status_derived(status: DisplayReady), priority: PriorityP0), children: RowChildrenExpandable { count: 0 } }, + RelationRow { node: specimen_shared(), children: RowChildrenExpandable { count: 3 } }, + RelationRow { node: specimen_node(slug: "argv-emit-posix", title: "Emit a typed argv for a POSIX shell, with a title long enough to show how a row behaves when its title runs well past the width a short one needs", status: node_status_derived(status: DisplayReview), priority: PriorityP2), children: RowChildrenExpandable { count: 12 } }, + RelationRow { node: specimen_node(slug: "argv-literal-census", title: "Count the argv literals still written by hand", status: node_status_derived(status: DisplayDone), priority: PriorityP3), children: RowChildrenExpandable { count: 0 } }, + RelationRow { node: specimen_node(slug: "argv-string-splitting", title: "Split an argv from a string", status: node_status_derived(status: DisplaySuperseded), priority: PriorityP3), children: RowChildrenExpandable { count: 1 } }, + RelationRow { node: specimen_node(slug: "argv-windows-quoting", title: "Quote an argv for a Windows command line", status: NodeStatusUnavailable { reason: "the plan carries no row for this node" }, priority: PriorityP2), children: RowChildrenExpandable { count: 0 } }, + ], + undeclared: [], + } +} + +// The same rows with every status derived, so the heading reads closed over total. +fn specimen_relation_sub_issues_derived() -> NodeRelation { + let all = specimen_relation_sub_issues() + NodeRelation { kind: all.kind, rows: all.rows.take(n: 5), undeclared: all.undeclared } +} + +fn specimen_relation_empty() -> NodeRelation { + NodeRelation { kind: RelationBlocking, rows: [], undeclared: [] } +} + +fn specimen_chain_section() -> Fragment { + specimen_section(label: "Hierarchy: node chain", body: [ + specimen_note(text: "Four levels: every level shown"), + specimen_row(children: [node_chain(chain: specimen_chain_short())]), + specimen_note(text: "Seven levels: the first, a counted control for the middle, and the last two"), + specimen_row(children: [node_chain(chain: specimen_chain_long())]), + specimen_note(text: "A lineage the view refused"), + specimen_row(children: [node_chain(chain: specimen_chain_refused())]), + ]) +} + +fn specimen_chip_section() -> Fragment { + specimen_section(label: "Chip", body: [ + specimen_row(children: [ + chip(input: Chip { text: "shell-typed-invocation" }), + chip(input: Chip { text: "dp-m2-shell-residue-retired" }), + ]), + ]) +} + +fn specimen_priority_section() -> Fragment { + specimen_section(label: "Priority mark", body: [ + specimen_row(children: [ + priority_mark(input: PriorityMark { priority: PriorityP0, explanation: none }), + priority_mark(input: PriorityMark { priority: PriorityP1, explanation: none }), + priority_mark(input: PriorityMark { priority: PriorityP2, explanation: none }), + priority_mark(input: PriorityMark { priority: PriorityP3, explanation: none }), + ]), + ]) +} + +// The row at the grid density, in a bare table of the specimen's own: no production table uses +// this density until the results grid adopts the row. +fn specimen_grid_row_section() -> Fragment { + specimen_section(label: "Node row, grid density", body: [ + el_attrs("table", [attr(name: "class", value: specimen_rows_class)], [ + el_attrs("thead", [], [node_row_header(density: NodeRowDensityGrid)]), + el_attrs("tbody", [], map([specimen_parent(), specimen_shared()], n => node_row(input: NodeRow { node: n, place: NodeRowFlat, density: NodeRowDensityGrid }))), + ]), + ]) +} + +fn specimen_relation_section() -> Fragment { + specimen_section(label: "Hierarchy: relation table", body: [ + el_attrs("div", [attr(name: "class", value: specimen_relations_class)], [ + relation_table(input: RelationTable { relation: specimen_relation_empty(), depth: 0 }), + relation_table(input: RelationTable { relation: specimen_relation_sub_issues_derived(), depth: 1 }), + ]), + specimen_note(text: "Above: a relation with no rows, and sub-issues one level down with every status derived"), + ]) +} + +// The hierarchy composite: the head of an issue page (the chain chip over the title) and its +// Dependencies card, the three relations of the fixture issue one relation-section gap apart. +fn specimen_issue_composite() -> List { + [ + el_attrs("div", [attr(name: "class", value: specimen_issue_header_class)], [ + node_chain(chain: specimen_chain_long()), + el_attrs("h1", [attr(name: "class", value: specimen_issue_title_class)], [txt(specimen_self().title)]), + ]), + card(input: Card { + edge: CardEdgePlain, + label_row: Present { value: CardLabelRow { label: "Dependencies", trailing: [] } }, + body: [ + el_attrs("div", [attr(name: "class", value: specimen_relations_class)], [ + relation_table(input: RelationTable { relation: specimen_relation_parent(), depth: 0 }), + relation_table(input: RelationTable { relation: specimen_relation_blocked_by(), depth: 0 }), + relation_table(input: RelationTable { relation: specimen_relation_sub_issues(), depth: 0 }), + ]), + ], + }), + ] +} + +// The compact work summary, over planted attempts. Fixtures that only the round ledger can +// describe go through the production derivation (gunbc.roadmap.roadmap_build_meter +// build_meter_view_of), so what is shown is what an attempt with those records would show; the +// two that carry typed operation kinds are supplied as views, because no producer emits those +// kinds yet. One fixture is a real run: its rounds were captured from the attempt's event log by +// issue-tracker-shots/meter/convert-real.cjs and are embedded here unchanged; its stages are +// planted to match its recorded terminal (the harness's 40-step budget). +data specimen_meter_class: String = "specimen-meter" +data specimen_meters_class: String = "specimen-meters" +data specimen_meter_subject: String = "frontend-elements" +data specimen_meter_first_round_ms: Int = 1790694000000 +data specimen_meter_round_ms: Int = 47000 + +data specimen_real_run_rounds: List = [ + RoundObservation { step: 1, input_tokens: 2152, output_tokens: 186, epoch_ms: 1789887228000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 2, input_tokens: 2526, output_tokens: 201, epoch_ms: 1789887241000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 3, input_tokens: 4432, output_tokens: 299, epoch_ms: 1789887259000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 4, input_tokens: 6735, output_tokens: 315, epoch_ms: 1789887283000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 5, input_tokens: 8280, output_tokens: 280, epoch_ms: 1789887307000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 6, input_tokens: 10018, output_tokens: 3303, epoch_ms: 1789887474000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 7, input_tokens: 14503, output_tokens: 396, epoch_ms: 1789887503000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 8, input_tokens: 17385, output_tokens: 2436, epoch_ms: 1789887627000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 9, input_tokens: 22564, output_tokens: 1356, epoch_ms: 1789887703000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 10, input_tokens: 24940, output_tokens: 5653, epoch_ms: 1789887971000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 11, input_tokens: 35282, output_tokens: 6359, epoch_ms: 1789888278000, files_changed: Present { value: 0 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 12, input_tokens: 43510, output_tokens: 6519, epoch_ms: 1789888589000, files_changed: Present { value: 2 }, tool_calls: Present { value: 2 } }, + RoundObservation { step: 13, input_tokens: 50285, output_tokens: 2903, epoch_ms: 1789888734000, files_changed: Present { value: 3 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 14, input_tokens: 53231, output_tokens: 576, epoch_ms: 1789888768000, files_changed: Present { value: 3 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 15, input_tokens: 53935, output_tokens: 529, epoch_ms: 1789888794000, files_changed: Present { value: 4 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 16, input_tokens: 54619, output_tokens: 1070, epoch_ms: 1789888845000, files_changed: Present { value: 5 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 17, input_tokens: 55730, output_tokens: 1178, epoch_ms: 1789888903000, files_changed: Present { value: 5 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 18, input_tokens: 57198, output_tokens: 392, epoch_ms: 1789888924000, files_changed: Present { value: 5 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 19, input_tokens: 57747, output_tokens: 1478, epoch_ms: 1789889002000, files_changed: Present { value: 6 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 20, input_tokens: 59266, output_tokens: 775, epoch_ms: 1789889040000, files_changed: Present { value: 8 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 21, input_tokens: 60082, output_tokens: 244, epoch_ms: 1789889055000, files_changed: Present { value: 8 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 22, input_tokens: 60517, output_tokens: 428, epoch_ms: 1789889077000, files_changed: Present { value: 8 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 23, input_tokens: 61102, output_tokens: 354, epoch_ms: 1789889153000, files_changed: Present { value: 8 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 24, input_tokens: 61517, output_tokens: 447, epoch_ms: 1789889295000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 25, input_tokens: 62011, output_tokens: 84, epoch_ms: 1789889302000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 26, input_tokens: 62137, output_tokens: 140, epoch_ms: 1789889412000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 27, input_tokens: 62788, output_tokens: 154, epoch_ms: 1789889534000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 28, input_tokens: 63016, output_tokens: 143, epoch_ms: 1789889542000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 29, input_tokens: 63314, output_tokens: 300, epoch_ms: 1789889662000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 30, input_tokens: 63733, output_tokens: 106, epoch_ms: 1789889779000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 31, input_tokens: 63958, output_tokens: 446, epoch_ms: 1789889912000, files_changed: Present { value: 9 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 32, input_tokens: 68811, output_tokens: 278, epoch_ms: 1789889934000, files_changed: Present { value: 10 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 33, input_tokens: 69253, output_tokens: 71, epoch_ms: 1789890052000, files_changed: Present { value: 10 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 34, input_tokens: 70235, output_tokens: 201, epoch_ms: 1789890062000, files_changed: Present { value: 10 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 35, input_tokens: 70624, output_tokens: 2186, epoch_ms: 1789890164000, files_changed: Present { value: 10 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 36, input_tokens: 72855, output_tokens: 270, epoch_ms: 1789890182000, files_changed: Present { value: 11 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 37, input_tokens: 73167, output_tokens: 115, epoch_ms: 1789890189000, files_changed: Present { value: 11 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 38, input_tokens: 73326, output_tokens: 115, epoch_ms: 1789890312000, files_changed: Present { value: 11 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 39, input_tokens: 73518, output_tokens: 119, epoch_ms: 1789890435000, files_changed: Present { value: 11 }, tool_calls: Present { value: 1 } }, + RoundObservation { step: 40, input_tokens: 73714, output_tokens: 229, epoch_ms: 1789890564000, files_changed: Present { value: 11 }, tool_calls: Present { value: 1 } }, +] + +fn specimen_meter_round(step: Int, tool_calls: Int, files_changed: Int, output_tokens: Int) -> RoundObservation { + RoundObservation { + step: step, + input_tokens: 9000 + step * 650, + output_tokens: output_tokens, + epoch_ms: specimen_meter_first_round_ms + step * specimen_meter_round_ms, + files_changed: Present { value: files_changed }, + tool_calls: Present { value: tool_calls }, + } +} + +// Six rounds of ordinary work: mostly tool calls, one round of reasoning alone, two changed-path +// rises. +fn specimen_meter_spell(base: Int, files: Int) -> List { + [ + specimen_meter_round(step: base + 1, tool_calls: 3, files_changed: files, output_tokens: 420), + specimen_meter_round(step: base + 2, tool_calls: 1, files_changed: files, output_tokens: 180), + specimen_meter_round(step: base + 3, tool_calls: 2, files_changed: files + 1, output_tokens: 2600), + specimen_meter_round(step: base + 4, tool_calls: 0, files_changed: files + 1, output_tokens: 5200), + specimen_meter_round(step: base + 5, tool_calls: 4, files_changed: files + 1, output_tokens: 900), + specimen_meter_round(step: base + 6, tool_calls: 1, files_changed: files + 2, output_tokens: 1300), + ] +} + +fn specimen_meter_spells(spells: Int) -> List { + flat_map([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].take(n: spells), i => specimen_meter_spell(base: i * 6, files: i * 2)) +} + +fn specimen_meter_segment(kind: WorkflowSegmentKind, state: WorkflowSegmentState, detail: String) -> WorkflowSegment { + WorkflowSegment { kind: kind, state: state, detail: detail } +} + +// The six attempt stages with the given states, in order, each with an optional detail. +fn specimen_meter_stages(states: List, details: List) -> List { + map(enumerate([EnvironmentWorkflowSegment, WorkspaceWorkflowSegment, AgentWorkflowSegment, SubmissionWorkflowSegment, VerificationWorkflowSegment, PublicationWorkflowSegment]), pair => + specimen_meter_segment( + kind: pair.second, + state: match states.skip(n: pair.first).first() { Present { value: st } => st Absent => WorkflowSegmentPending }, + detail: match details.skip(n: pair.first).first() { Present { value: d } => d Absent => "" })) +} + +fn specimen_meter_running() -> ProviderExecutionState { + ProviderRunning { activity: "Agent working" } +} + +// THE TYPED SPECIMENS go through the producer the rail uses (gunbc.roadmap.roadmap_build_meter +// build_meter_view_of): a round ledger plus, per round, the operations the dispatcher recorded by +// tool (gunbc.harness.harness_tool HarnessTool). Only the four tools the harness has appear: run, +// read, write and edit. Neither specimen draws a mark itself. +fn specimen_ops(step: Int, run: Int, read: Int, write: Int, edit: Int) -> BuildMeterRoundOperations { + BuildMeterRoundOperations { step: step, operations: [ + BuildMeterToolCount { tool: RunCommandTool, count: run }, + BuildMeterToolCount { tool: ReadFileTool, count: read }, + BuildMeterToolCount { tool: WriteFileTool, count: write }, + BuildMeterToolCount { tool: EditFileTool, count: edit }, + ] } +} + +data specimen_forty: List = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, +] + +// Tool-heavy, mixed: reading dominates the first rounds, then running is the steady beat; a write +// lands every seventh round and an edit every fourth, with a longer reasoning gap every tenth. +fn specimen_tool_heavy_ops(i: Int) -> BuildMeterRoundOperations { + specimen_ops(step: i, + run: if i <= 6 { i % 2 } else if i % 3 == 0 { 3 } else { 1 + i % 2 }, + read: if i <= 6 { 3 + i % 3 } else if i % 5 == 0 { 2 } else { i % 2 }, + write: if i % 7 == 0 { 1 } else { 0 }, + edit: if i % 4 == 0 { 1 + i % 3 % 2 } else { 0 }) +} + +fn specimen_tool_heavy_ledger() -> List { + map(specimen_forty, i => RoundObservation { + step: i, + input_tokens: 9000 + i * 700, + output_tokens: if i % 10 == 0 { 4200 } else { 300 + (i % 4) * 350 }, + epoch_ms: specimen_meter_first_round_ms + i * specimen_meter_round_ms + (i / 10) * 60000, + files_changed: Present { value: i / 7 + i / 4 }, + tool_calls: Present { value: 0 }, + }) +} + +// Single tool, forty times: every round ran one command. The marks stay one colour; the rounds +// differ only in what was recorded about them (output size, and a longer pause every eighth). +fn specimen_single_tool_ledger() -> List { + map(specimen_forty, i => RoundObservation { + step: i, + input_tokens: 9000 + i * 400, + output_tokens: if i % 8 == 0 { 1800 } else { 220 }, + epoch_ms: specimen_meter_first_round_ms + i * specimen_meter_round_ms + (i / 8) * 90000, + files_changed: Present { value: 0 }, + tool_calls: Present { value: 1 }, + }) +} + +fn specimen_typed_derived(attempt_key: String, ledger: List, typed: List) -> BuildMeterView { + build_meter_view_of(node_id: specimen_meter_subject, attempt_key: attempt_key, + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentActive], details: []), + rounds: RoundsObserved { rounds: ledger }, provider: specimen_meter_running(), handoff: HandoffAwaitingWorker, stale: false, typed: typed) +} + +fn specimen_tool_heavy_view() -> BuildMeterView { + specimen_typed_derived(attempt_key: "4b7e2c9105d8a3f6", ledger: specimen_tool_heavy_ledger(), typed: map(specimen_forty, i => specimen_tool_heavy_ops(i: i))) +} + +fn specimen_single_tool_view() -> BuildMeterView { + specimen_typed_derived(attempt_key: "2d5b8f04a1c7e936", ledger: specimen_single_tool_ledger(), typed: map(specimen_forty, i => specimen_ops(step: i, run: 1, read: 0, write: 0, edit: 0))) +} + +type SpecimenMeter { + caption: String + view: BuildMeterView +} + +fn specimen_meter_derived(attempt_key: String, segments: List, rounds: HarnessRoundLedger, provider: ProviderExecutionState, handoff: CandidateHandoffState, stale: Bool) -> BuildMeterView { + build_meter_view_of(node_id: specimen_meter_subject, attempt_key: attempt_key, segments: segments, rounds: rounds, provider: provider, handoff: handoff, stale: stale) +} + +// THE METER FIXTURES, NAMED, so that each can be drawn on its own (a claim draws one fixture, not a +// section of them). The pose strip shows five of them, in its order: working, stopped, paused on a +// stale revision, and the two naps side by side. The BuildMeter section adds the other four: a real +// run, a single-tool stress run, a legacy attempt and a refusal before the first round. +type SpecimenMeterFixture + = SpecimenMeterToolHeavy + | SpecimenMeterVerificationFailed + | SpecimenMeterStale + | SpecimenMeterWaiting + | SpecimenMeterPublished + | SpecimenMeterRealRun + | SpecimenMeterSingleTool + | SpecimenMeterLegacy + | SpecimenMeterRefused + +data specimen_pose_fixtures: List = [ + SpecimenMeterToolHeavy, SpecimenMeterVerificationFailed, SpecimenMeterStale, SpecimenMeterWaiting, SpecimenMeterPublished, +] + +data specimen_more_fixtures: List = [ + SpecimenMeterRealRun, SpecimenMeterSingleTool, SpecimenMeterLegacy, SpecimenMeterRefused, +] + +fn specimen_agent_active() -> List { + specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentActive], details: []) +} + +fn specimen_meter(fixture: SpecimenMeterFixture) -> SpecimenMeter { + match fixture { + SpecimenMeterToolHeavy => SpecimenMeter { caption: "Tool-heavy, mixed typed operations — supplied", view: specimen_tool_heavy_view() } + SpecimenMeterVerificationFailed => SpecimenMeter { caption: "Verification failed after 18 rounds", view: specimen_meter_derived( + attempt_key: "7742b0e19c3d5a68", + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentFailed], details: ["", "", "", "", "3 of 41 claims red"]), + rounds: RoundsObserved { rounds: specimen_meter_spells(spells: 3) }, provider: ProviderCompleted { activity: "Agent complete" }, handoff: HandoffVerificationFailed { head_sha: "9c41d07", verdict_key: "failed" }, stale: false) } + SpecimenMeterStale => SpecimenMeter { caption: "Stale: launched on an earlier revision", view: specimen_meter_derived( + attempt_key: "5f1c9a2e77d04b13", segments: specimen_agent_active(), + rounds: RoundsObserved { rounds: specimen_meter_spells(spells: 2) }, provider: specimen_meter_running(), handoff: HandoffAwaitingWorker, stale: true) } + SpecimenMeterWaiting => SpecimenMeter { caption: "Waiting for a seat, no round yet", view: specimen_meter_derived( + attempt_key: "0bd4418ce2a75f90", segments: specimen_agent_active(), + rounds: RoundsObserved { rounds: [] }, provider: ProviderAwaitingSeat { detail: "no free seat in the serving group" }, handoff: HandoffAwaitingWorker, stale: false) } + SpecimenMeterPublished => SpecimenMeter { caption: "Published", view: specimen_meter_derived( + attempt_key: "e69a15d83f0c2b74", + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete], details: []), + rounds: RoundsObserved { rounds: specimen_meter_spells(spells: 4).take(n: 21) }, provider: ProviderCompleted { activity: "Agent complete" }, handoff: HandoffPublished { head_sha: "9c41d07", pr_number: 12861, url: "https://github.com/gunb-ai/gunbc/pull/12861" }, stale: false) } + SpecimenMeterRealRun => SpecimenMeter { caption: "Real run: shell-typed-invocation-a5908ed38768df111 (rounds from its log; budget spent)", view: specimen_meter_derived( + attempt_key: "a5908ed38768df111", + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentStopped], details: ["", "", "the harness ended the turn at its 40-step budget"]), + rounds: RoundsObserved { rounds: specimen_real_run_rounds }, provider: ProviderBudgetSpent { activity: "Agent stopped at its step budget", detail: "40 of 40 steps taken" }, handoff: HandoffAwaitingWorker, stale: false) } + SpecimenMeterSingleTool => SpecimenMeter { caption: "Single-tool stress — 40 runs", view: specimen_single_tool_view() } + SpecimenMeterLegacy => SpecimenMeter { caption: "Legacy: refused, rounds not recorded", view: specimen_meter_derived( + attempt_key: "0cfc18886a3d3b10", + segments: specimen_meter_stages(states: [WorkflowSegmentRefused, WorkflowSegmentRefused, WorkflowSegmentRefused, WorkflowSegmentRefused, WorkflowSegmentPending, WorkflowSegmentRefused], details: ["environment admission receipt unavailable", "worktree marker unavailable", "attempt predates modeled admission and event capture", "no submission capture", "", "legacy branch has no modeled workspace publication receipt"]), + rounds: RoundsUnobserved { reason: "the attempt predates round capture, so its rounds were not recorded" as NonEmptyStr }, provider: ProviderEventsRefused { activity: "legacy attempt — supersedable", detail: "attempt predates modeled admission and event capture" }, handoff: HandoffAwaitingWorker, stale: false) } + SpecimenMeterRefused => SpecimenMeter { caption: "Refused before the first round", view: specimen_meter_derived( + attempt_key: "c81e3f6a9d2047b5", + segments: specimen_meter_stages(states: [WorkflowSegmentRefused], details: ["admission receipt records a refusal: no seat class admits this work"]), + rounds: RoundsObserved { rounds: [] }, provider: ProviderNotStarted, handoff: HandoffAwaitingWorker, stale: false) } + } +} + +fn specimen_meter_node(row: SpecimenMeter) -> Fragment { + el_attrs("div", [attr(name: "class", value: specimen_meter_class)], [ + specimen_note(text: row.caption), + build_meter(summary: compact_work_summary_of_view(subject: join(["specimen-", row.view.attempt_key], ""), view: row.view), link_attributes: []), + ]) +} + +// THE FIELD LIST, at the rail's width, over a fixture that holds every arm and the cases that +// used to break the rail: an identity and a link title far longer than the value column, an +// absent value with its reason, the status control, the assignee control, and a meter whose hover +// digest names a failure (the longest digest the rail has to keep inside the page). +data specimen_field_rail_class: String = "specimen-field-rail" + +fn specimen_face(initial: String) -> Fragment { + el_attrs("span", [attr(name: "class", value: "site-avatar")], [txt(initial)]) +} + +fn specimen_field_list_fixture() -> FieldList { + let long_identity = "continuous-integration-auto-heal-publisher@gunbc-fabric.iam.gserviceaccount.com" + FieldList { rows: [ + FieldRow { label: "Status", value: FieldStatusControl { status: DisplayActive, detail: [specimen_note(text: "Planning: ready. Current obligation: none. Attempt: running.")] } }, + FieldRow { label: "Assignee", value: FieldIdentityControl { + person: FieldPerson { face: specimen_face(initial: "C"), identity: long_identity, full: Present { value: join(["Auto-heal publisher — ", long_identity], "") } }, + action: "Change assignee", + editor: [specimen_note(text: "The assign form opens here, in place of the identity.")], + } }, + FieldRow { label: "Work", value: FieldWork { summary: compact_work_summary_of_view(subject: "specimen-field-list", view: specimen_meter(fixture: SpecimenMeterVerificationFailed).view), link_attributes: [] } }, + FieldRow { label: "Reporter", value: FieldAbsent { reason: "Not recorded: an authored roadmap row has no creation event" } }, + FieldRow { label: "Component", value: FieldText { text: "@shell", full: none } }, + FieldRow { label: "Parent", value: FieldLinks { links: [FieldLink { text: "DP-M2: shell-to-dag batch 1 lands, then batch 2 retires the residue of the shell invocation layer", href: issue_page_path(node_id: "dp-m2-shell-residue-retired"), full: none }], unlinked: [] } }, + FieldRow { label: "Unmet Prerequisites", value: FieldAction { text: "10 issues", attributes: [] } }, + FieldRow { label: "Return To", value: FieldIdentity { person: FieldPerson { face: specimen_face(initial: "B"), identity: "brian.searls.operator.longname@example.com", full: none } } }, + FieldRow { label: "Delivery", value: FieldText { text: "attempt 0cfc1888…", full: Present { value: "attempt 0cfc18886a3d3b10" } } }, + ] } +} + +fn specimen_field_list_section() -> Fragment { + specimen_section(label: "FieldList", body: [ + el_attrs("div", [attr(name: "class", value: specimen_field_rail_class)], [field_list(input: specimen_field_list_fixture())]), + ]) +} + +// THE WORKER AT ITS REAL SIZE. One meter per pose, drawn at the strip's own 22px with no +// enlargement, so the robot's antenna, eyes, lids and mouth are judged where they are shown: +// working (deadpan), verification failed (sad), paused on a stale revision (outlined), waiting for a +// seat (asleep on the floor) and published (content, beside the finish rule). Each is the production meter over a supplied view; the +// page's theme switch shows the same strip in the other scheme. +data specimen_pose_strip_class: String = "specimen-pose-strip" + +fn specimen_pose_section() -> Fragment { + specimen_section(label: "Worker poses, actual size", body: [ + el_attrs("div", [attr(name: "class", value: specimen_pose_strip_class)], map(specimen_pose_fixtures, f => specimen_meter_node(row: specimen_meter(fixture: f)))), + ]) +} + +// THE METER'S CHANGES OF SCENE. One attempt in each scene, each drawn by the server: the production +// meter over a view from the production derivation. A control hands one drawing to the live meter +// the way the page's workflow observation hands it a newer rendering +// (gunbc.site.specimen_meter_client), so what plays here is what a live attempt plays: the facts +// change at once, a new scene is drawn once it has held for the meter's dwell, a stopped or +// published one at once, and a flap faster than the dwell is never drawn. +data specimen_scene_subject: String = "specimen-scenes" +data specimen_scene_attempt: String = "3b9e0d6c41a7f528" + +type SpecimenScene { + key: String + label: String + view: BuildMeterView +} + +fn specimen_scene_template_id(key: String) -> String { + join(["specimen-scene-", key], "") +} + +fn specimen_scene_view(segments: List, provider: ProviderExecutionState, handoff: CandidateHandoffState, stale: Bool) -> BuildMeterView { + specimen_meter_derived(attempt_key: specimen_scene_attempt, segments: segments, rounds: RoundsObserved { rounds: specimen_meter_spells(spells: 2) }, provider: provider, handoff: handoff, stale: stale) +} + +fn specimen_scenes() -> List { + let agent_active = specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentActive], details: []) + [ + SpecimenScene { key: "working", label: "Working", view: specimen_scene_view(segments: agent_active, provider: specimen_meter_running(), handoff: HandoffAwaitingWorker, stale: false) }, + SpecimenScene { key: "waiting", label: "Waiting for a seat", view: specimen_scene_view(segments: agent_active, provider: ProviderAwaitingSeat { detail: "no free seat in the serving group" }, handoff: HandoffAwaitingWorker, stale: false) }, + SpecimenScene { key: "idle", label: "Between turns", view: specimen_scene_view(segments: agent_active, provider: ProviderCompleted { activity: "Agent turn complete" }, handoff: HandoffAwaitingWorker, stale: false) }, + SpecimenScene { key: "stopped", label: "Stopped", view: specimen_scene_view( + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentFailed], details: ["", "", "the agent's turn ended in an error"]), + provider: ProviderFailed { activity: "Agent failed", detail: "the provider ended the turn with an error" }, handoff: HandoffAwaitingWorker, stale: false) }, + SpecimenScene { key: "published", label: "Published", view: specimen_scene_view( + segments: specimen_meter_stages(states: [WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete, WorkflowSegmentComplete], details: []), + provider: ProviderCompleted { activity: "Agent complete" }, handoff: HandoffPublished { head_sha: "9c41d07", pr_number: 12861, url: "https://github.com/gunb-ai/gunbc/pull/12861" }, stale: false) }, + SpecimenScene { key: "paused", label: "Stale", view: specimen_scene_view(segments: agent_active, provider: specimen_meter_running(), handoff: HandoffAwaitingWorker, stale: true) }, + ] +} + +fn specimen_scene_meter(view: BuildMeterView) -> Fragment { + build_meter(summary: compact_work_summary_of_view(subject: specimen_scene_subject, view: view), link_attributes: []) +} + +fn specimen_scene_control(attribute: String, value: String, label: String) -> Fragment { + el_attrs("span", [attr(name: attribute, value: value), attr(name: specimen_target_attribute, value: specimen_scene_subject)], [ + button(input: Button { label: label, idiom: IssueButtonOutlined, size: ButtonSmall, availability: IssueActionEnabled }), + ]) +} + +fn specimen_scene_section() -> Fragment { + let scenes = specimen_scenes() + specimen_section(label: "BuildMeter: changes of scene", body: concat([ + specimen_note(text: join([ + "Each control hands the meter the server's drawing of this attempt in that scene, as the page's observation would. The facts change at once; a new scene is drawn once it has held for ", + to_string(build_meter_dwell_ms / 1000), " s, and stopped or published at once. Flap alternates working and waiting every ", + to_string(specimen_flap_ms), " ms, faster than the dwell, so the drawing does not change.", + ], "")), + el_attrs("div", [attr(name: "class", value: specimen_meter_class)], match scenes.first() { + Present { value: first } => [specimen_scene_meter(view: first.view)] + Absent => [] + }), + specimen_row(children: concat( + map(scenes, sc => specimen_scene_control(attribute: specimen_hand_attribute, value: specimen_scene_template_id(key: sc.key), label: sc.label)), + [specimen_scene_control(attribute: specimen_flap_attribute, value: join([specimen_scene_template_id(key: "working"), " ", specimen_scene_template_id(key: "waiting")], ""), label: "Flap working and waiting")])), + ], map(scenes, sc => el_attrs("template", [attr(name: "id", value: specimen_scene_template_id(key: sc.key))], [specimen_scene_meter(view: sc.view)])))) +} + +fn specimen_meter_section() -> Fragment { + specimen_section(label: "BuildMeter", body: [ + el_attrs("div", [attr(name: "class", value: specimen_meters_class)], map(specimen_more_fixtures, f => specimen_meter_node(row: specimen_meter(fixture: f)))), + ]) +} + +// EACH REGISTERED ELEMENT'S SPECIMEN. Which sections an element has is a total function of the +// element identity (gunbc.site.element.styles SiteElement), so registering an element without +// naming its sections does not compile; the sections are a closed set of names, so which sections +// an element has is a fact apart from drawing them, and each name is drawn by one total match. The +// page shows them in the registry's order, after the composites and before the foundations (the +// text roles and the adopted heights). +type SpecimenSection + = SpecimenCardSection + | SpecimenHeadingSection + | SpecimenStatusSection + | SpecimenButtonSection + | SpecimenChipSection + | SpecimenPrioritySection + | SpecimenNodeRowSection + | SpecimenRelationSection + | SpecimenChainSection + | SpecimenPoseSection + | SpecimenSceneSection + | SpecimenMeterSection + | SpecimenFieldListSection + +fn specimen_element_sections(element: SiteElement) -> List { + match element { + SiteCard => [SpecimenCardSection] + SiteSectionHeading => [SpecimenHeadingSection] + SiteStatusChip => [SpecimenStatusSection] + SiteButton => [SpecimenButtonSection] + SiteChip => [SpecimenChipSection] + SitePriorityMark => [SpecimenPrioritySection] + SiteNodeRow => [SpecimenNodeRowSection] + SiteRelationTable => [SpecimenRelationSection] + SiteNodeChain => [SpecimenChainSection] + SiteBuildMeter => [SpecimenPoseSection, SpecimenSceneSection, SpecimenMeterSection] + SiteFieldList => [SpecimenFieldListSection] + } +} + +fn specimen_section_node(section: SpecimenSection) -> Fragment { + match section { + SpecimenCardSection => specimen_card_section() + SpecimenHeadingSection => specimen_heading_section() + SpecimenStatusSection => specimen_status_section() + SpecimenButtonSection => specimen_button_section() + SpecimenChipSection => specimen_chip_section() + SpecimenPrioritySection => specimen_priority_section() + SpecimenNodeRowSection => specimen_grid_row_section() + SpecimenRelationSection => specimen_relation_section() + SpecimenChainSection => specimen_chain_section() + SpecimenPoseSection => specimen_pose_section() + SpecimenSceneSection => specimen_scene_section() + SpecimenMeterSection => specimen_meter_section() + SpecimenFieldListSection => specimen_field_list_section() + } +} + +fn specimen_content() -> List { + [ + el_attrs("main", [attr(name: "class", value: specimen_page_class)], concat( + concat([specimen_source_section(), specimen_composite()], specimen_issue_composite()), + concat( + flat_map(site_elements, e => map(specimen_element_sections(element: e), section => specimen_section_node(section: section))), + [specimen_text_section(), specimen_height_section()]))), + ] +} + +// What the specimen adds around a role's sample. Code is shown as the product shows inline code: +// on the subtle ground, with the code radius and a hair of side padding. +fn specimen_text_setting_decls(role: ElementText) -> List { + match role { + ElementCode => [ + decl(prop: Background, value: palette_var(slot: SlotSubtle)), + decl(prop: BorderRadius, value: scale_var(t: radius_code)), + decl(prop: Padding, value: scale_pair(a: space_0, b: space_2)), + ] + ElementBody => [] + ElementLabel => [] + ElementCardLabel => [] + ElementSectionHeading => [] + ElementControl => [] + ElementTitle => [] + ElementProseHeading => [] + ElementIdentifier => [] + } +} + +fn specimen_text_rule(role: ElementText) -> BuildRule { + StaticRule { selector: RawSelector { text: join([".", specimen_text_class, "[", specimen_text_role_attribute, "=\"", element_text_name(role: role), "\"]"], "") }, decls: concat(element_text_decls(role: role), specimen_text_setting_decls(role: role)) } +} + +fn specimen_height_rule(row: ElementHeightRow) -> BuildRule { + StaticRule { selector: RawSelector { text: join([".", specimen_height_class, "[", specimen_height_attribute, "=\"", row.token.name, "\"]"], "") }, decls: [ + decl(prop: Height, value: scale_var(t: row.token)), + ] } +} + +fn specimen_layout_rules() -> List { + concat([ + StaticRule { selector: ClassSelector { name: specimen_page_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: Gap, value: scale_var(t: card_outer_margin)), + decl(prop: Padding, value: scale_var(t: card_outer_margin)), + decl(prop: MinHeight, value: "100vh"), + decl(prop: Background, value: palette_var(slot: SlotGround)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_row_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexWrap, value: "wrap"), + decl(prop: AlignItems, value: "center"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: Margin, value: scale_pair(a: space_8, b: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_note_class }, decls: element_text_decls(role: ElementLabel) }, + StaticRule { selector: ClassSelector { name: specimen_pose_strip_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexWrap, value: "wrap"), + decl(prop: Gap, value: scale_var(t: card_outer_margin)), + decl(prop: Padding, value: scale_var(t: card_outer_margin)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_field_rail_class }, decls: [ + decl(prop: MaxWidth, value: scale_var(t: field_rail_width)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_meters_class }, decls: [ + decl(prop: Display, value: "grid"), + decl(prop: GridTemplateColumns, value: "repeat(auto-fill, minmax(min(100%, 30ch), 1fr))"), + decl(prop: AlignItems, value: "start"), + decl(prop: Gap, value: scale_var(t: card_outer_margin)), + decl(prop: Padding, value: scale_var(t: card_outer_margin)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_meter_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: MinWidth, value: scale_var(t: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_issue_header_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: AlignItems, value: "flex-start"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: Padding, value: scale_var(t: card_outer_margin)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_issue_title_class }, decls: concat([ + decl(prop: Margin, value: scale_var(t: space_0)), + ], element_text_decls(role: ElementTitle)) }, + StaticRule { selector: ClassSelector { name: specimen_rows_class }, decls: [ + decl(prop: Width, value: "100%"), + decl(prop: BorderCollapse, value: "collapse"), + ] }, + StaticRule { selector: ClassSelector { name: specimen_relations_class }, decls: [ + decl(prop: Display, value: "flex"), + decl(prop: FlexDirection, value: "column"), + decl(prop: Gap, value: scale_var(t: relation_section_gap)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_role_class }, decls: [ + decl(prop: Display, value: "grid"), + decl(prop: GridTemplateColumns, value: "16ch minmax(0, 1fr)"), + decl(prop: AlignItems, value: "baseline"), + decl(prop: RawProperty { name: "justify-items" }, value: "start"), + decl(prop: Gap, value: scale_var(t: space_8)), + decl(prop: Margin, value: scale_pair(a: space_8, b: space_0)), + ] }, + StaticRule { selector: ClassSelector { name: specimen_height_class }, decls: concat([ + decl(prop: Display, value: "flex"), + decl(prop: AlignItems, value: "center"), + decl(prop: Padding, value: scale_pair(a: space_0, b: space_8)), + decl(prop: Margin, value: scale_pair(a: space_8, b: space_0)), + decl(prop: Background, value: palette_var(slot: SlotSubtle)), + decl(prop: BoxShadow, value: join(["inset 0 0 0 ", scale_var(t: card_border), " ", palette_var(slot: SlotBorder)], "")), + ], element_text_decls(role: ElementBody)) }, + ], concat( + map(element_text_roles, r => specimen_text_rule(role: r)), + map(element_height_rows(), r => specimen_height_rule(row: r)))) +} + +fn specimen_styles() -> SiteStyles { + site_extend_styles(base: site_styles(rules: element_rules()), rules: specimen_layout_rules(), themes: element_themes()) +} + +fn specimen_page() -> Fragment { + site_document(title: "gunbc — specimen", heading: "specimen", styles: specimen_styles(), content: specimen_content()) +} diff --git a/dag/gunbc/site/specimen_meter_client.dag b/dag/gunbc/site/specimen_meter_client.dag new file mode 100644 index 00000000000..bf44e0d5a02 --- /dev/null +++ b/dag/gunbc/site/specimen_meter_client.dag @@ -0,0 +1,81 @@ +module gunbc.site.specimen_meter_client + +import std.types { Int, List, String } +import extdeps.languages.typescript.program { + TsStmt, TsExpr, ts_ident, ts_lit_str, ts_lit_num, ts_member, ts_call, ts_arrow, ts_block, ts_const, ts_return, + ts_expr_stmt, ts_if, ts_binop, ts_new, ts_object, ts_field, ts_index, +} +import gunbc.site.build_meter_client { build_meter_fresh_event } +import gunbc.site.element.build_meter { build_meter_subject_attribute } + +// THE SPECIMEN'S CHANGES OF SCENE. A control carrying the hand attribute hands the meter its target +// names the server's rendering kept in the template its value names, by the event the page's +// workflow observation uses (gunbc.site.build_meter_client build_meter_fresh_event), so the +// specimen shows what a live attempt changing scene shows and nothing the meter would not. A +// control carrying the flap attribute hands its two renderings in turn, faster than the meter's +// dwell: the facts change each time and the drawing never does. +data specimen_hand_attribute: String = "data-specimen-hand" +data specimen_flap_attribute: String = "data-specimen-flap" +data specimen_target_attribute: String = "data-specimen-target" +data specimen_flap_ms: Int = 1500 +data specimen_flap_count: Int = 6 + +fn specimen_control_attribute(name: String) -> TsExpr { + ts_call(callee: ts_member(object: ts_ident(name: "control"), property: "getAttribute"), args: [ts_lit_str(value: name)]) +} + +fn specimen_meter_bind_statements() -> List { + [ + ts_const(name: "siteSpecimenHand", value: ts_arrow(params: ["control"], body: ts_block(stmts: [ + ts_const(name: "meter", value: ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "querySelector"), args: [ + ts_binop( + left: ts_binop(left: ts_lit_str(value: join(["[", build_meter_subject_attribute, "=\""], "")), op: "+", right: specimen_control_attribute(name: specimen_target_attribute)), + op: "+", + right: ts_lit_str(value: "\"]")), + ])), + ts_const(name: "hand", value: ts_arrow(params: ["id"], body: ts_block(stmts: [ + ts_const(name: "still", value: ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "getElementById"), args: [ts_ident(name: "id")])), + ts_if(cond: ts_binop(left: ts_ident(name: "meter"), op: "&&", right: ts_ident(name: "still")), then_stmt: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "meter"), property: "dispatchEvent"), args: [ + ts_new(callee: ts_ident(name: "CustomEvent"), args: [ + ts_lit_str(value: build_meter_fresh_event), + ts_object(fields: [ts_field(key: "detail", value: ts_call( + callee: ts_member(object: ts_member(object: ts_member(object: ts_ident(name: "still"), property: "content"), property: "firstElementChild"), property: "cloneNode"), + args: [ts_ident(name: "true")]))]), + ]), + ])), + ]), else_stmt: none), + ]))), + ts_expr_stmt(expr: ts_call(callee: ts_member(object: ts_ident(name: "control"), property: "addEventListener"), args: [ + ts_lit_str(value: "click"), + ts_arrow(params: [], body: ts_block(stmts: [ + ts_const(name: "one", value: specimen_control_attribute(name: specimen_hand_attribute)), + ts_if(cond: ts_ident(name: "one"), then_stmt: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "hand"), args: [ts_ident(name: "one")])), + ts_return(expr: none), + ]), else_stmt: none), + ts_const(name: "two", value: ts_call( + callee: ts_member(object: ts_binop(left: specimen_control_attribute(name: specimen_flap_attribute), op: "||", right: ts_lit_str(value: "")), property: "split"), + args: [ts_lit_str(value: " ")])), + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_call(callee: ts_member(object: ts_ident(name: "Array"), property: "from"), args: [ts_object(fields: [ts_field(key: "length", value: ts_lit_num(text: to_string(specimen_flap_count)))])]), property: "forEach"), + args: [ts_arrow(params: ["_", "k"], body: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "setTimeout"), args: [ + ts_arrow(params: [], body: ts_block(stmts: [ + ts_expr_stmt(expr: ts_call(callee: ts_ident(name: "hand"), args: [ts_index(object: ts_ident(name: "two"), index: ts_binop(left: ts_ident(name: "k"), op: "%", right: ts_lit_num(text: "2")))])), + ])), + ts_binop(left: ts_ident(name: "k"), op: "*", right: ts_lit_num(text: to_string(specimen_flap_ms))), + ])), + ]))])), + ])), + ])), + ]))), + ts_expr_stmt(expr: ts_call( + callee: ts_member(object: ts_call(callee: ts_member(object: ts_ident(name: "Array"), property: "from"), args: [ + ts_call(callee: ts_member(object: ts_ident(name: "document"), property: "querySelectorAll"), args: [ + ts_lit_str(value: join(["[", specimen_hand_attribute, "], [", specimen_flap_attribute, "]"], "")), + ]), + ]), property: "forEach"), + args: [ts_ident(name: "siteSpecimenHand")])), + ] +} diff --git a/dag/gunbc/site/status.dag b/dag/gunbc/site/status.dag index 4f2d603159e..29bae9840a9 100644 --- a/dag/gunbc/site/status.dag +++ b/dag/gunbc/site/status.dag @@ -1,12 +1,8 @@ module gunbc.site.status import std.markup { Fragment, attr } import gunbc.site.markup { el_attrs, txt } -import extdeps.color.srgb { Srgb8 } -import gunbc.design.palette { Swatch, swatch_paint } -import gunbc.design.material { CssDecl, decl } -import extdeps.languages.css.properties { CustomProperty } -// One semantic palette for issue lists, detail summaries and dependency labels. +// The status inks these badges read are slots of gunbc.site.element.palette. // Blue also paints the graph's selected-node ink. Completion remains subdued. fn status_tone(label: String) -> String { if label == "Ready" || label == "ready" { "ready" } @@ -17,10 +13,3 @@ fn status_tone(label: String) -> String { fn status_badge(label: String) -> Fragment { el_attrs("span", [attr(name: "class", value: "site-status"), attr(name: "data-status-tone", value: status_tone(label: label))], [txt(label)]) } -fn status_palette(night: Bool) -> List { - [ - decl(prop: CustomProperty { name: "status-assigned-ink" }, value: swatch_paint(sw: Swatch { name: "status-assigned", color: if night { Srgb8 { r: 146, g: 181, b: 238 } } else { Srgb8 { r: 54, g: 90, b: 155 } } })), - decl(prop: CustomProperty { name: "status-ready-ink" }, value: swatch_paint(sw: Swatch { name: "status-ready", color: if night { Srgb8 { r: 146, g: 201, b: 166 } } else { Srgb8 { r: 38, g: 105, b: 68 } } })), - decl(prop: CustomProperty { name: "status-blocked-ink" }, value: swatch_paint(sw: Swatch { name: "status-blocked", color: if night { Srgb8 { r: 226, g: 181, b: 109 } } else { Srgb8 { r: 142, g: 83, b: 21 } } })), - ] -} diff --git a/dag/test/claim/extdeps/issue_tracker_witness_test.dag b/dag/test/claim/extdeps/issue_tracker_witness_test.dag index 9ed7339c320..192027e71f6 100644 --- a/dag/test/claim/extdeps/issue_tracker_witness_test.dag +++ b/dag/test/claim/extdeps/issue_tracker_witness_test.dag @@ -409,10 +409,11 @@ test fn w_stock_sidebar_roster_is_verbatim() -> Bool { // The interface version records the latest cited session PLUS the owner-directed vocabulary // additions (the version comment on the row says why); the 2026-09-24 bump carries the action -// vocabulary, the 2026-09-25 bump the capture-verified assignee display rule. The pin keeps the -// row from drifting back. +// vocabulary, the 2026-09-25 bump the capture-verified assignee display rule, the 2026-10-04 bump +// the measured design language and the captured dark palette. The pin keeps the row from +// drifting back. test fn w_interface_version_cites_capture_date() -> Bool { - (issue_tracker_interface_version as String) == "2026-09-27" + (issue_tracker_interface_version as String) == "2026-10-04" } test fn w_status_vocabulary_wire_labels_are_ui_spellings() -> Bool { diff --git a/dag/test/claim/long/roadmap_page_witness_test.dag b/dag/test/claim/long/roadmap_page_witness_test.dag index 03b7405b2fd..a48e1b8d857 100644 --- a/dag/test/claim/long/roadmap_page_witness_test.dag +++ b/dag/test/claim/long/roadmap_page_witness_test.dag @@ -1059,7 +1059,7 @@ data fx_issue_token_names: List = ["text", "text-dim", "border", "bg-hov // max-width page containers never ship, and every issue token is declared three times (day root, night attribute, // dark media query) -- all of them in the issue-tracker root block. fn fx_css_part_holds(css: String, rail_widths: Int, grid_templates: Int, token_declarations: Int) -> Bool { - occurrence_count(s: css, pattern: "flex: 0 1 34ch") == rail_widths + occurrence_count(s: css, pattern: "flex: 1 0 var(--w-field-rail)") == rail_widths && !string_contains(s: css, pattern: "action-live") && occurrence_count(s: css, pattern: join(["grid-template-columns: ", workspace_issue_tracks()], "")) == grid_templates && !string_contains(s: css, pattern: ".issue-table-header { display: grid") @@ -1650,17 +1650,21 @@ test fn witness_view_switch_and_filter_contracts() -> Bool { } // THE ISSUE DETAIL PAGE (owner ruling 2026-09-22: each issue has a detail page and a separate -// Work tab). The page renders the decided row view and link panel: the head carries the issue id +// Work tab). The page renders the decided row view and hierarchy view: the head carries the issue id // (the dense mono line) and the title; the field panel carries status / assignee / component / // blocking / blocked-by / standing; the assignee is the event-log standing's holder, not the // domain owner. Tabs ship as decided chips + panels — the stylesheet decides which panel shows. fn issue_detail_fixture_view() -> gunbc.roadmap_presentation.WorkRowView { + issue_detail_fixture_view_scheduled(scheduling: gunbc.roadmap_presentation.SchedulingReady) +} + +fn issue_detail_fixture_view_scheduled(scheduling: gunbc.roadmap_presentation.WorkScheduling) -> gunbc.roadmap_presentation.WorkRowView { gunbc.roadmap_presentation.WorkRowView { node_id: "issue-detail-specimen", headline: "Specimen issue", summary: none, lifecycle: gunbc.roadmap_presentation.OpenLifecycle, - scheduling: gunbc.roadmap_presentation.SchedulingReady, + scheduling: scheduling, superseded_by: "", activity: gunbc.roadmap_presentation.ActiveWork { summary: "Agent working", @@ -1684,21 +1688,31 @@ fn issue_detail_fixture_view() -> gunbc.roadmap_presentation.WorkRowView { } } -fn issue_detail_fixture_panel() -> gunbc.roadmap_presentation.IssueLinkPanel { +// THE FIXTURE'S HIERARCHY: the specimen is a member of the parent issue and the child a member of +// the specimen (membership); the parent blocks the specimen and the specimen blocks the child +// (dependency). The parent declares no parent of its own, so the chain is the lineage walk's +// refusal -- the content witnesses below are about the page body, not a rooted path. Statuses are +// supplied: the row producer that derives them is another interface. +fn issue_detail_fixture_member(identity: String, id: String, content: String, project: String) -> RoadmapNode { + gunbc.roadmap_authority.roadmap_node(identity: identity, id: id, owner: "", carriers: [], line: gunbc.roadmap_status.AuthoredLine { done: false, content: content }, + parent: Present { value: gunbc.roadmap_model.MemberOf { project: project, contribution: "fixture contribution" as NonEmptyStr } }) +} + +fn issue_detail_fixture_hierarchy() -> gunbc.roadmap.roadmap_hierarchy_view.IssueHierarchyView { let nodes = [ - fx_authored(identity: "rnfx_idspec", id: "issue-detail-specimen", done: false, content: "Specimen issue"), + issue_detail_fixture_member(identity: "rnfx_idspec", id: "issue-detail-specimen", content: "Specimen issue", project: "issue-detail-parent"), fx_authored(identity: "rnfx_idparent", id: "issue-detail-parent", done: false, content: "Parent issue"), - fx_authored(identity: "rnfx_idchild", id: "issue-detail-child", done: false, content: "Child issue"), + issue_detail_fixture_member(identity: "rnfx_idchild", id: "issue-detail-child", content: "Child issue", project: "issue-detail-specimen"), ] let edges = [ gunbc.roadmap_authority.edge(child: "issue-detail-specimen", parent: "issue-detail-parent"), gunbc.roadmap_authority.edge(child: "issue-detail-child", parent: "issue-detail-specimen"), ] - gunbc.roadmap_presentation.issue_link_panel( - rn: fx_authored(identity: "rnfx_idspec", id: "issue-detail-specimen", done: false, content: "Specimen issue"), + gunbc.roadmap.roadmap_hierarchy_view.issue_hierarchy_view( + node: gunbc.roadmap_authority.nid(s: "issue-detail-specimen"), nodes: nodes, edges: edges, - memberships: [gunbc.roadmap_nesting.RoadmapMembership { member: "issue-detail-specimen", project: "issue-detail-parent", contribution: "fixture contribution" }, gunbc.roadmap_nesting.RoadmapMembership { member: "issue-detail-child", project: "issue-detail-specimen", contribution: "fixture contribution" }]) + status_of: fn(rn) { gunbc.roadmap.roadmap_hierarchy_view.node_status_derived(status: gunbc.roadmap_issue_status.DisplayReady) }) } fn fixture_human_ref() -> gunbc.principal_projection.PrincipalRef { @@ -1718,17 +1732,17 @@ fn fixture_teammate_legacy() -> gunbc.roadmap.roadmap_event_log.EventPrincipal { } // Content witnesses use the real page body, header and client tag. Their synthetic issue is -// absent from the live roadmap authority, so supply its typed undeclared alignment result at -// the production render boundary instead of rebuilding the entire authority for every field. -// The full stylesheet and authority-backed alignment are separate rendering inputs. -fn fx_issue_detail_body(view: gunbc.roadmap_presentation.WorkRowView, panel: gunbc.roadmap_presentation.IssueLinkPanel, observation: gunbc.roadmap_presentation.ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }, profiles: List) -> Fragment { +// absent from the live roadmap authority, so its hierarchy view is supplied at the production +// render boundary instead of rebuilding the entire authority for every field. The full stylesheet +// is a separate rendering input. +fn fx_issue_detail_body(view: gunbc.roadmap_presentation.WorkRowView, hierarchy: gunbc.roadmap.roadmap_hierarchy_view.IssueHierarchyView, observation: gunbc.roadmap_presentation.ObservationImpactView, attempts: List, events: List, viewer: String, recents: List, expected_revision: String, command_context: IssueFormContext = IssueFormContext { operation: "", csrf: "" }, profiles: List) -> Fragment { Element { tag: "html", attributes: [ attr(name: "lang", value: "en" ) ], children: [ Element { tag: "body", attributes: [], children: [ gunbc.site.document.site_header(page: "issue"), - gunbc.roadmap_page.issue_detail_page_with_alignment(view: view, panel: panel, observation: observation, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context, alignment: gunbc.roadmap.roadmap_alignment.alignment_resolve_chain(chain: gunbc.roadmap.roadmap_alignment.AlignmentChainRowUndeclared { node_id: view.node_id }, turns_elapsed: 1, state: gunbc.roadmap.roadmap_alignment.alignment_base_cadence_state())), + gunbc.roadmap_page.issue_detail_page(view: view, hierarchy: hierarchy, observation: observation, attempts: attempts, events: events, viewer: viewer, recents: recents, expected_revision: expected_revision, command_context: command_context), gunbc.roadmap_page.dispatch_client_script(), ] }, ], @@ -1738,7 +1752,7 @@ fn fx_issue_detail_body(view: gunbc.roadmap_presentation.WorkRowView, panel: gun fn issue_detail_fixture_html() -> HtmlEmitResult { try_serialize_html_source(node: fx_issue_detail_body( view: issue_detail_fixture_view(), - panel: issue_detail_fixture_panel(), + hierarchy: issue_detail_fixture_hierarchy(), observation: gunbc.roadmap_presentation.ObservationsNominal, attempts: [], events: [], @@ -1767,19 +1781,18 @@ test fn witness_issue_detail_page_renders_header_fields_and_tabs() -> Bool { && string_contains(s: h, pattern: "") - && string_contains(s: h, pattern: "blocking") - } + && occurrence_count(s: h, pattern: "Ready") == 2 + && !string_contains(s: h, pattern: "open") + && string_contains(s: h, pattern: "0/1") + && string_contains(s: h, pattern: "aria-label=\"Parent: Parent issue\"") + && string_contains(s: h, pattern: "aria-label=\"Sub-issues: Child issue\"") + && !string_contains(s: h, pattern: "
Blocked By
") + && !string_contains(s: h, pattern: "Unmet prerequisites") + && string_contains(s: h, pattern: "
Blocking
") + } +} + +// THE RAIL'S PREREQUISITE ROW ANSWERS WHAT IS HOLDING THE ISSUE, from the row's own readiness. The +// fixture issue declares one prerequisite in every case below; only the scheduling position +// changes. Ready: the prerequisite is satisfied, so there is no row (the declared prerequisite +// stays listed on the Dependencies tab). Not ready with a dependency gap: the row counts the +// gap's blockers and is labelled as unmet. Not ready for another reason: no row. Done: readiness +// was not assessed, so the rail states the declared count under the neutral label and claims +// nothing about it. The red this pins: counting the Blocked by relation would read "1 issue" in +// all four. +test fn witness_issue_rail_counts_unmet_prerequisites_from_readiness() -> Bool { + let blocked_by = issue_detail_fixture_hierarchy().dependency.blocked_by + let row = fn(scheduling) { + extdeps.languages.html.serialize_html_source(node: gunbc.site.element.field_list.field_list(input: gunbc.site.element.field_list.FieldList { rows: gunbc.roadmap_page.issue_prerequisites_rail_rows(view: issue_detail_fixture_view_scheduled(scheduling: scheduling), blocked_by: blocked_by) })) + } + let unmet = row(gunbc.roadmap_presentation.SchedulingNotReady { missing: [gunbc.roadmap.roadmap_readiness.GapDependencies { blockers: ["issue-detail-parent"] }] }) + let other_gap = row(gunbc.roadmap_presentation.SchedulingNotReady { missing: [gunbc.roadmap.roadmap_readiness.GapCentering] }) + count(blocked_by.rows) == 1 + && row(gunbc.roadmap_presentation.SchedulingReady) == "
" + && string_contains(s: unmet, pattern: "
Unmet Prerequisites
") + && other_gap == "
" + && string_contains(s: row(gunbc.roadmap_presentation.SchedulingDone), pattern: "
Prerequisites
") + && !string_contains(s: row(gunbc.roadmap_presentation.SchedulingDone), pattern: "Unmet") } // The assignment mapping after the legacy-session cleanup (owner 2026-09-26): when an attempt @@ -2186,7 +2230,7 @@ test fn witness_issue_detail_parent_and_blocking_render_separately() -> Bool { test fn witness_issue_detail_attempt_does_not_invent_a_workflow_assignee() -> Bool { match try_serialize_html_source(node: fx_issue_detail_body( view: issue_detail_fixture_view(), - panel: issue_detail_fixture_panel(), + hierarchy: issue_detail_fixture_hierarchy(), observation: gunbc.roadmap_presentation.ObservationsNominal, attempts: [ completed_attempt_progress(node_id: "issue-detail-specimen") ], events: [], @@ -2197,14 +2241,108 @@ test fn witness_issue_detail_attempt_does_not_invent_a_workflow_assignee() -> Bo profiles: [])) { EmitRejected { reason: _ } => false EmitOk { html: h } => - let region = string_at(parts: split(s: string_at(parts: split(s: h, delimiter: "issue-field-label\">assignee"), index: 1), delimiter: "Assignee"), index: 1), delimiter: "assignee") + && string_contains(s: h, pattern: "
Assignee
") } } +// THE RAIL'S COMPACT WORK SUMMARY, THROUGH THE REAL PROJECTION. The rail's own function is given +// a real attempt projection (the belt witness's completed attempt, built by +// workflow_attempt_progress) and an assignment held by the Fabric workflow. It places the +// BuildMeter element inside the rail's wrapper, as a link into this issue's Work tab carrying the +// tab chip, and the element's revision is the producer's revision of that projection: the real +// attempt reached the real element by the real derivation. +fn fx_rail_meter_html(holder: gunbc.roadmap.roadmap_event_log.EventPrincipal?, attempts: List) -> String { + extdeps.languages.html.serialize_html_source(node: gunbc.site.element.field_list.field_list(input: gunbc.site.element.field_list.FieldList { rows: gunbc.roadmap_page.issue_rail_build_meter_rows( + view: issue_detail_fixture_view(), + attempts: attempts, + assignment: gunbc.roadmap.roadmap_event_log.IssueAssignmentProjection { holder: holder, return_to: none }) })) +} + +test fn witness_issue_rail_renders_the_build_meter_for_a_fabric_attempt() -> Bool { + let attempt = completed_attempt_progress(node_id: "issue-detail-specimen") + let h = fx_rail_meter_html(holder: Present { value: fixture_fabric_principal() }, attempts: [ attempt ]) + string_contains(s: h, pattern: join(["
Work
Bool { + let attempt = completed_attempt_progress(node_id: "issue-detail-specimen") + fx_rail_meter_html(holder: Present { value: fixture_fabric_principal() }, attempts: []) == "
" + && fx_rail_meter_html(holder: Present { value: fixture_human_principal() }, attempts: [ attempt ]) == "
" + && fx_rail_meter_html(holder: none, attempts: [ attempt ]) == "
" +} + +// THE RAIL IS ONE FIELD LIST AND HAS NO EMPTY ROWS. The rail of the fixture issue (one attempt, +// no events) is rendered by the page's own function: it holds exactly one field list, every row +// the page composed is one row of it, no row's value is empty, and none of the retired rail +// markup (the issue-field rows, including the empty ones optional rows used to leave) remains. +// The reporter, which the ledger cannot name, is an absent value with its reason. +test fn witness_issue_rail_is_one_field_list_with_no_empty_rows() -> Bool { + let rows = gunbc.roadmap_page.issue_rail_rows(view: issue_detail_fixture_view(), hierarchy: issue_detail_fixture_hierarchy(), attempts: [ completed_attempt_progress(node_id: "issue-detail-specimen") ], events: [], viewer: "", recents: [], expected_revision: "") + let h = extdeps.languages.html.serialize_html_source(node: gunbc.roadmap_page.issue_metadata_rail_node(view: issue_detail_fixture_view(), hierarchy: issue_detail_fixture_hierarchy(), attempts: [ completed_attempt_progress(node_id: "issue-detail-specimen") ], events: [], viewer: "", recents: [], expected_revision: "")) + string_contains(s: h, pattern: "