From a49a876d0da9f2eac4afdc1fcf0bef330b06dc1b Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 13:48:58 -0400 Subject: [PATCH 01/16] =?UTF-8?q?docs(gildi):=20add=20Plan=202=20=E2=80=94?= =?UTF-8?q?=20crest=20module=20+=20Guilds=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second implementation slice: the deterministic heraldic crest generator (pure FNV-1a hash → blazon honouring the rule of tincture → inline SVG Crest, with a monogram fallback), the typed-catalog-search Guilds section (kind:Group spec.type:guild joined to owned practices), the curated guild card composing the crest with name/description/practice+aspect chips, and a plain plugin README. Grounded in the design (§4 card system, §5 crests, §7 data model); catalog-react import sources flagged for verification against installed versions. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-21-gildi-crest-and-guilds-plan.md | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md diff --git a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md new file mode 100644 index 0000000..428dd5d --- /dev/null +++ b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md @@ -0,0 +1,400 @@ +# Guild Hall hub (`gildi`) — Plan 2: Crest module + Guilds section + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Guild Hall placeholder with its first real section — a grid of curated **guild cards**, each led by a **generated heraldic crest** — backed by typed catalog search. Plus a plain plugin README. + +**Architecture:** A self-contained, deterministic **crest generator** (pure functions → an inline SVG `Crest` component) forms the visual keystone. A `useGuilds` hook queries the catalog (`kind:Group, spec.type:guild`) via `catalogApiRef`; a `GuildCard` composes the crest with the guild's name, description, stewards, and its practice/aspect chips; a `GuildsSection` renders the responsive grid and mounts into `GuildHallPage`. First frontend catalog-querying in the repo. + +**Tech Stack:** Backstage 1.52 new frontend system, `@backstage/plugin-catalog-react@^3.1.0` (`catalogApiRef`, `EntityRefLink`), `@backstage/core-components`, `@backstage/frontend-test-utils`, React 18, TypeScript ~5.8. + +## Global Constraints + +- Node **22 || 24**; Yarn **4.13** via Corepack. Build/test through `ws`: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi `, `ws test leidangr` (= `make ci`). Commit via `ws commit leidangr `; one shell command per call; no raw `git commit`/`push`. +- **New frontend system only.** Keep the plugin's public surface stable (`gildiPlugin` default export, `rootRouteRef`, `GuildHallPage`). +- **Presentation is a first-class goal** — curated cards, never a raw metadata dump. Clean display names + a brief description. +- **Crests are deterministic** from the group id/name (same input → same arms), honour the **rule of tincture** (a colour field pairs with a metal charge, or vice versa), render as **inline SVG** (no assets, no network), and fall back to a **monogram** if generation is disabled. Guilds only in v1. +- **Guild data model** (from the seed): guilds are `Group` `spec.type: guild` with a `siliconsaga.org/stewards` annotation (e.g. `aspect:security`); practices are `Component` `spec.type: practice` with `spec.owner` = the guild; the practice's aspect is on its `siliconsaga.org/aspect` annotation. +- Design source of truth: `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` §4 (card system), §5 (crests), §7 (data model). +- Verify catalog-react / hook imports (`useApi`, `catalogApiRef`, `useAsync`) against the installed package versions — the `tsc`/build gate is the safety net, as in Plan 1. + +--- + +### Task 0: Plugin README + +**Files:** Create `plugins/gildi/README.md` + +- [ ] **Step 1: Write the README** (`plugins/gildi/README.md`): +```markdown +# @siliconsaga/plugin-gildi + +The **Guild Hall** — a curated overview of the practice layer for this Backstage instance: the guilds, their practices and aspects, active drives, and recent sagas. Built on Backstage's new frontend system. + +## Status + +Foundation shipped: a page at `/guild-hall` with a sidebar entry. Sections land incrementally (guilds first). See `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` in the leidangr repo for the full design. + +## Install + +Registered in the app's `features` (see `packages/app/src/App.tsx`): + + import gildiPlugin from '@siliconsaga/plugin-gildi'; + // ... + features: [/* … */, gildiPlugin] + +## Development + + ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test + +This package is developed inside the leidangr instance for now; its package name (`@siliconsaga/*`) is scoped for later extraction to a standalone plugin repo. +``` + +- [ ] **Step 2: Commit.** Bodyfile `.commits/gildi-readme.md` (`add: plugins/gildi/README.md`), message `docs(gildi): add plugin README`. Run `ws commit leidangr .commits/gildi-readme.md`. + +--- + +### Task 1: The heraldic crest generator + +**Files:** +- Create `plugins/gildi/src/crest/hash.ts` +- Create `plugins/gildi/src/crest/blazon.ts` +- Create `plugins/gildi/src/crest/Crest.tsx` +- Create `plugins/gildi/src/crest/blazon.test.ts` +- Create `plugins/gildi/src/crest/index.ts` + +**Interfaces:** +- Produces: `blazonFor(seed: string): Blazon` (deterministic); `` (inline SVG); `Blazon` type. Task 2's `GuildCard` consumes ``. + +- [ ] **Step 1: Deterministic hash** (`plugins/gildi/src/crest/hash.ts`): +```ts +// FNV-1a 32-bit — stable across runs/machines (no Math.random / Date). +export function hashSeed(seed: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} +``` + +- [ ] **Step 2: Write the failing blazon test** (`plugins/gildi/src/crest/blazon.test.ts`): +```ts +import { blazonFor } from './blazon'; + +describe('blazonFor', () => { + it('is deterministic for the same seed', () => { + expect(blazonFor('security-gildi')).toEqual(blazonFor('security-gildi')); + }); + it('honours the rule of tincture (field colour ↔ charge metal or vice versa)', () => { + const colours = ['gules', 'azure', 'vert', 'sable', 'purpure']; + const metals = ['or', 'argent']; + for (const seed of ['a', 'security-gildi', 'release-captains-gildi', 'platform', 'data', 'zzz']) { + const b = blazonFor(seed); + const fieldIsColour = colours.includes(b.fieldTincture); + const chargeIsMetal = metals.includes(b.chargeTincture); + expect(fieldIsColour).toBe(!chargeIsMetal ? fieldIsColour : true); + // exactly one side is a metal + expect(metals.includes(b.fieldTincture)).not.toEqual(metals.includes(b.chargeTincture)); + } + }); + it('produces distinct arms for distinct seeds', () => { + const a = blazonFor('security-gildi'); + const b = blazonFor('release-captains-gildi'); + expect(JSON.stringify(a)).not.toEqual(JSON.stringify(b)); + }); +}); +``` + +- [ ] **Step 3: Run it, expect FAIL** (`blazonFor` not defined): `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test crest/blazon`. + +- [ ] **Step 4: Implement the blazon** (`plugins/gildi/src/crest/blazon.ts`): +```ts +import { hashSeed } from './hash'; + +export const COLOURS = { gules: '#a83a3a', azure: '#2f5fa0', vert: '#3a7a4a', sable: '#2b2b30', purpure: '#6b3a6b' } as const; +export const METALS = { or: '#d9b23a', argent: '#dcdce0' } as const; +export type Tincture = keyof typeof COLOURS | keyof typeof METALS; +export type Division = 'plain' | 'perPale' | 'perFess' | 'perBend'; +export type Charge = 'key' | 'chevron' | 'mullet' | 'roundel' | 'cross'; +export interface Blazon { + division: Division; + fieldTincture: Tincture; // the field (may be two tinctures for divided fields; second derives) + fieldTincture2: Tincture; + chargeTincture: Tincture; + charge: Charge; + fieldIsColour: boolean; // true → charge is a metal (rule of tincture) +} + +const COLOUR_KEYS = Object.keys(COLOURS) as (keyof typeof COLOURS)[]; +const METAL_KEYS = Object.keys(METALS) as (keyof typeof METALS)[]; +const DIVISIONS: Division[] = ['plain', 'perPale', 'perFess', 'perBend']; +const CHARGES: Charge[] = ['key', 'chevron', 'mullet', 'roundel', 'cross']; + +export function blazonFor(seed: string): Blazon { + const h = hashSeed(seed); + // Draw independent choices from different byte-lanes of the hash. + const fieldIsColour = (h & 1) === 0; + const colour = COLOUR_KEYS[(h >>> 1) % COLOUR_KEYS.length]; + const colour2 = COLOUR_KEYS[(h >>> 4) % COLOUR_KEYS.length]; + const metal = METAL_KEYS[(h >>> 7) % METAL_KEYS.length]; + const division = DIVISIONS[(h >>> 9) % DIVISIONS.length]; + const charge = CHARGES[(h >>> 12) % CHARGES.length]; + return { + division, + fieldTincture: fieldIsColour ? colour : metal, + fieldTincture2: fieldIsColour ? colour2 : metal, + chargeTincture: fieldIsColour ? metal : colour, + charge, + fieldIsColour, + }; +} + +export function tinctureHex(t: Tincture): string { + return (COLOURS as Record)[t] ?? (METALS as Record)[t]; +} +``` + +- [ ] **Step 5: Run the test, expect PASS**: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test crest/blazon`. Fix until green (in particular the rule-of-tincture assertion: exactly one of field/charge is a metal). + +- [ ] **Step 6: The Crest SVG component** (`plugins/gildi/src/crest/Crest.tsx`) — renders a heater shield clipped to its outline, a field (with optional division), and the charge; monogram fallback: +```tsx +import { blazonFor, tinctureHex, Charge } from './blazon'; + +const SHIELD = 'M9 7 L51 7 L51 33 Q51 52 30 63 Q9 52 9 33 Z'; + +function ChargeShape({ charge, fill }: { charge: Charge; fill: string }) { + switch (charge) { + case 'key': return (); + case 'chevron': return ; + case 'mullet': return ; + case 'roundel': return ; + case 'cross': return ; + } +} + +export function Crest({ seed, size = 44, title }: { seed: string; size?: number; title?: string }) { + if (!seed) return null; + const b = blazonFor(seed); + const id = `gildi-crest-${seed.replace(/[^a-z0-9]/gi, '')}`; + const f1 = tinctureHex(b.fieldTincture); + const f2 = tinctureHex(b.fieldTincture2); + const charge = tinctureHex(b.chargeTincture); + return ( + + + + {b.division === 'plain' && } + {b.division === 'perPale' && (<>)} + {b.division === 'perFess' && (<>)} + {b.division === 'perBend' && (<>)} + + + + + ); +} +``` + +- [ ] **Step 7: Barrel export** (`plugins/gildi/src/crest/index.ts`): +```ts +export { Crest } from './Crest'; +export { blazonFor, tinctureHex, COLOURS, METALS } from './blazon'; +export type { Blazon, Tincture, Charge, Division } from './blazon'; +``` + +- [ ] **Step 8: Typecheck + full package test**: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi tsc` then `... test`. Both green. + +- [ ] **Step 9: Commit.** `.commits/gildi-crest.md` (`add: plugins/gildi/src/crest`), message `feat(gildi): deterministic heraldic crest generator`. `ws commit leidangr .commits/gildi-crest.md`. + +--- + +### Task 2: The Guilds section (typed catalog search + guild cards) + +**Files:** +- Create `plugins/gildi/src/guilds/useGuilds.ts` +- Create `plugins/gildi/src/guilds/GuildCard.tsx` +- Create `plugins/gildi/src/guilds/GuildsSection.tsx` +- Create `plugins/gildi/src/guilds/GuildsSection.test.tsx` +- Modify `plugins/gildi/src/components/GuildHallPage.tsx` (mount the section) +- Modify `plugins/gildi/package.json` (already declares `@backstage/plugin-catalog-react`; add `@backstage/core-plugin-api` if `useApi` resolves there) + +**Interfaces:** +- Consumes: `` from Task 1. +- Produces: `useGuilds()` → `{ guilds: GuildView[], loading, error }`; ``. Later slices reuse `GuildView` shaping. + +- [ ] **Step 1: The data hook** (`plugins/gildi/src/guilds/useGuilds.ts`) — one query for guilds, one for practices, joined by owner. Verify `useApi`'s import source against the installed packages (`@backstage/core-plugin-api` or `@backstage/frontend-plugin-api`): +```ts +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import type { Entity } from '@backstage/catalog-model'; + +export interface GuildView { + name: string; + title: string; + description?: string; + entityRef: string; // e.g. group:default/security-gildi + stewardAspects: string[]; // from siliconsaga.org/stewards: 'aspect:security' + practices: { name: string; title: string; aspect?: string }[]; +} + +const STEWARDS = 'siliconsaga.org/stewards'; +const ASPECT = 'siliconsaga.org/aspect'; + +export function useGuilds() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const [guildsRes, practicesRes] = await Promise.all([ + catalog.getEntities({ filter: { kind: 'Group', 'spec.type': 'guild' } }), + catalog.getEntities({ filter: { kind: 'Component', 'spec.type': 'practice' } }), + ]); + const practicesByOwner = new Map(); + for (const p of practicesRes.items) { + const owner = (p.spec?.owner as string) ?? ''; + const key = owner.includes('/') ? owner : `group:default/${owner.replace(/^group:/, '')}`; + practicesByOwner.set(key, [...(practicesByOwner.get(key) ?? []), p]); + } + const guilds: GuildView[] = guildsRes.items.map(g => { + const ref = `group:default/${g.metadata.name}`; + const stewards = (g.metadata.annotations?.[STEWARDS] ?? '') + .split(',').map(s => s.trim()).filter(Boolean) + .filter(s => s.startsWith('aspect:')).map(s => s.slice('aspect:'.length)); + const practices = (practicesByOwner.get(ref) ?? []).map(p => ({ + name: p.metadata.name, + title: p.metadata.title ?? p.metadata.name, + aspect: p.metadata.annotations?.[ASPECT], + })); + return { + name: g.metadata.name, + title: g.metadata.title ?? g.metadata.name, + description: g.metadata.description, + entityRef: ref, + stewardAspects: stewards, + practices, + }; + }); + return guilds; + }, [catalog]); + return { guilds: state.value ?? [], loading: state.loading, error: state.error }; +} +``` + +- [ ] **Step 2: The guild card** (`plugins/gildi/src/guilds/GuildCard.tsx`) — crest + name + description + practice/aspect chips; whole card links to the entity page: +```tsx +import { Link } from '@backstage/core-components'; +import { Crest } from '../crest'; +import type { GuildView } from './useGuilds'; + +export function GuildCard({ guild }: { guild: GuildView }) { + const aspects = Array.from(new Set([ + ...guild.stewardAspects, + ...guild.practices.map(p => p.aspect).filter(Boolean) as string[], + ])); + return ( + +
+ +
+
{guild.title}
+ {guild.description &&
{guild.description}
} +
+ {guild.practices.map(p => ({p.title}))} + {aspects.map(a => ({a} aspect))} +
+
+
+ + ); +} + +function chip(c: string): React.CSSProperties { + return { fontSize: 10.5, padding: '2px 8px', borderRadius: 20, border: `1px solid ${c}80`, background: `${c}1a` }; +} +``` + +- [ ] **Step 3: The section** (`plugins/gildi/src/guilds/GuildsSection.tsx`) — grid + loading/empty/error: +```tsx +import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useGuilds } from './useGuilds'; +import { GuildCard } from './GuildCard'; + +export function GuildsSection() { + const { guilds, loading, error } = useGuilds(); + if (loading) return ; + if (error) return ; + if (guilds.length === 0) return

No guilds yet — define a Group with spec.type: guild.

; + return ( +
+ {guilds.map(g => ())} +
+ ); +} +``` + +- [ ] **Step 4: Mount into the page** (`plugins/gildi/src/components/GuildHallPage.tsx`) — replace the placeholder paragraph: +```tsx +import { Content, Header, Page } from '@backstage/core-components'; +import { GuildsSection } from '../guilds/GuildsSection'; + +export function GuildHallPage() { + return ( + +
+ +

Guilds

+ +
+ + ); +} +``` + +- [ ] **Step 5: Write the failing section test** (`plugins/gildi/src/guilds/GuildsSection.test.tsx`) — mock `catalogApiRef`, assert curated rendering: +```tsx +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { GuildsSection } from './GuildsSection'; + +const catalogApi = { + getEntities: async ({ filter }: any) => { + if (filter['spec.type'] === 'guild') { + return { items: [{ apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: 'security-gildi', title: 'Security guild', description: 'Keeps things safe.', annotations: { 'siliconsaga.org/stewards': 'aspect:security' } }, spec: { type: 'guild' } }] }; + } + return { items: [{ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'security-practice', title: 'Security practice', annotations: { 'siliconsaga.org/aspect': 'security' } }, spec: { type: 'practice', owner: 'group:default/security-gildi' } }] }; + }, +}; + +describe('GuildsSection', () => { + it('renders a curated guild card with name, description, practice and aspect', async () => { + await renderInTestApp( + + + , + ); + expect(await screen.findByText('Security guild')).toBeInTheDocument(); + expect(screen.getByText('Keeps things safe.')).toBeInTheDocument(); + expect(screen.getByText('Security practice')).toBeInTheDocument(); + expect(screen.getByText('security aspect')).toBeInTheDocument(); + }); +}); +``` +(Verify `TestApiProvider` is exported by `@backstage/frontend-test-utils` at the installed version; if not, import it from `@backstage/core-app-api`'s test utils equivalent surfaced for the new system — the build/test gate confirms.) + +- [ ] **Step 6: Run the section test**: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test guilds`. Iterate (import sources, entity shapes) until PASS. + +- [ ] **Step 7: Full gate**: `ws test leidangr` (make ci). Must PASS. + +- [ ] **Step 8 (human-gated — do NOT attempt): Visual check** — `make dev`, open `/guild-hall`, confirm the guild cards render with crests, names, descriptions, and practice/aspect chips, and clicking a card opens the guild's entity page. Note PENDING in the report. + +- [ ] **Step 9: Commit.** `.commits/gildi-guilds.md` (`add: plugins/gildi/src/guilds`, `plugins/gildi/src/components/GuildHallPage.tsx`, `plugins/gildi/package.json` if changed), message `feat(gildi): Guilds section — typed catalog search + heraldic guild cards`. `ws commit leidangr .commits/gildi-guilds.md`. + +--- + +## Self-Review + +- **Spec coverage:** crest generator (design §5) → Task 1; guild card + typed search + section (§4/§7) → Task 2; README → Task 0. Drives/chronicle/actions, entity-page decoration, dogfood remain later plans. +- **Placeholder scan:** Task 1's blazon/crest code is complete and deterministic. Task 2's hook/card/section carry full code; the only flagged unknowns are import sources (`useApi`, `TestApiProvider`) and the exact `catalog.getEntities` filter typing — each has an explicit verify-against-installed note, with the `tsc`/test gate as backstop (the Plan-1 precedent). +- **Type consistency:** `blazonFor`/`Blazon`/`Crest`/`GuildView`/`useGuilds`/`GuildsSection` used consistently across tasks; `` matches Task 1's signature. +- **Determinism:** crest uses FNV-1a over the seed — no `Math.random`/`Date`; the test asserts stability and the rule of tincture. From b651d35786bc78b77c8f2a158567050a444e7102 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 13:50:26 -0400 Subject: [PATCH 02/16] docs(gildi): add plugin README + fix Plan 2 crest test assertion Task 0 of Plan 2: a plain README for the gildi plugin (noticed missing post-#11). Also corrects the rule-of-tincture assertion in Plan 2's crest test snippet (the original had a garbled tautology) to the clean "exactly one of field/charge is a metal" check, before the crest task runs. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-21-gildi-crest-and-guilds-plan.md | 6 +----- plugins/gildi/README.md | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 plugins/gildi/README.md diff --git a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md index 428dd5d..13b4ad5 100644 --- a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md +++ b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md @@ -87,14 +87,10 @@ describe('blazonFor', () => { expect(blazonFor('security-gildi')).toEqual(blazonFor('security-gildi')); }); it('honours the rule of tincture (field colour ↔ charge metal or vice versa)', () => { - const colours = ['gules', 'azure', 'vert', 'sable', 'purpure']; const metals = ['or', 'argent']; for (const seed of ['a', 'security-gildi', 'release-captains-gildi', 'platform', 'data', 'zzz']) { const b = blazonFor(seed); - const fieldIsColour = colours.includes(b.fieldTincture); - const chargeIsMetal = metals.includes(b.chargeTincture); - expect(fieldIsColour).toBe(!chargeIsMetal ? fieldIsColour : true); - // exactly one side is a metal + // rule of tincture: exactly one of field/charge is a metal (colour on metal, or metal on colour) expect(metals.includes(b.fieldTincture)).not.toEqual(metals.includes(b.chargeTincture)); } }); diff --git a/plugins/gildi/README.md b/plugins/gildi/README.md new file mode 100644 index 0000000..7701595 --- /dev/null +++ b/plugins/gildi/README.md @@ -0,0 +1,21 @@ +# @siliconsaga/plugin-gildi + +The **Guild Hall** — a curated overview of the practice layer for this Backstage instance: the guilds, their practices and aspects, active drives, and recent sagas. Built on Backstage's new frontend system. + +## Status + +Foundation shipped: a page at `/guild-hall` with a sidebar entry. Sections land incrementally (guilds first). See `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` in the leidangr repo for the full design. + +## Install + +Registered in the app's `features` (see `packages/app/src/App.tsx`): + + import gildiPlugin from '@siliconsaga/plugin-gildi'; + // ... + features: [/* … */, gildiPlugin] + +## Development + + ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test + +This package is developed inside the leidangr instance for now; its package name (`@siliconsaga/*`) is scoped for later extraction to a standalone plugin repo. From 7243efcc2ae3738499a19e0b704836f898feceb1 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 15:05:49 -0400 Subject: [PATCH 03/16] feat(gildi): deterministic heraldic crest generator Pure FNV-1a hash -> blazon (rule of tincture: a colour field pairs with a metal charge or vice versa) -> inline SVG Crest component (shield + division + charge), deterministic per seed. Unit-tested for determinism, rule of tincture, and distinctness. Task 2's guild card renders . Co-Authored-By: Claude Opus 4.8 --- plugins/gildi/src/crest/Crest.tsx | 35 +++++++++++++++++++++ plugins/gildi/src/crest/blazon.test.ts | 20 ++++++++++++ plugins/gildi/src/crest/blazon.ts | 43 ++++++++++++++++++++++++++ plugins/gildi/src/crest/hash.ts | 9 ++++++ plugins/gildi/src/crest/index.ts | 3 ++ 5 files changed, 110 insertions(+) create mode 100644 plugins/gildi/src/crest/Crest.tsx create mode 100644 plugins/gildi/src/crest/blazon.test.ts create mode 100644 plugins/gildi/src/crest/blazon.ts create mode 100644 plugins/gildi/src/crest/hash.ts create mode 100644 plugins/gildi/src/crest/index.ts diff --git a/plugins/gildi/src/crest/Crest.tsx b/plugins/gildi/src/crest/Crest.tsx new file mode 100644 index 0000000..4261cf3 --- /dev/null +++ b/plugins/gildi/src/crest/Crest.tsx @@ -0,0 +1,35 @@ +import { blazonFor, tinctureHex, Charge } from './blazon'; + +const SHIELD = 'M9 7 L51 7 L51 33 Q51 52 30 63 Q9 52 9 33 Z'; + +function ChargeShape({ charge, fill }: { charge: Charge; fill: string }) { + switch (charge) { + case 'key': return (); + case 'chevron': return ; + case 'mullet': return ; + case 'roundel': return ; + case 'cross': return ; + } +} + +export function Crest({ seed, size = 44, title }: { seed: string; size?: number; title?: string }) { + if (!seed) return null; + const b = blazonFor(seed); + const id = `gildi-crest-${seed.replace(/[^a-z0-9]/gi, '')}`; + const f1 = tinctureHex(b.fieldTincture); + const f2 = tinctureHex(b.fieldTincture2); + const charge = tinctureHex(b.chargeTincture); + return ( + + + + {b.division === 'plain' && } + {b.division === 'perPale' && (<>)} + {b.division === 'perFess' && (<>)} + {b.division === 'perBend' && (<>)} + + + + + ); +} diff --git a/plugins/gildi/src/crest/blazon.test.ts b/plugins/gildi/src/crest/blazon.test.ts new file mode 100644 index 0000000..509b305 --- /dev/null +++ b/plugins/gildi/src/crest/blazon.test.ts @@ -0,0 +1,20 @@ +import { blazonFor } from './blazon'; + +describe('blazonFor', () => { + it('is deterministic for the same seed', () => { + expect(blazonFor('security-gildi')).toEqual(blazonFor('security-gildi')); + }); + it('honours the rule of tincture (field colour ↔ charge metal or vice versa)', () => { + const metals = ['or', 'argent']; + for (const seed of ['a', 'security-gildi', 'release-captains-gildi', 'platform', 'data', 'zzz']) { + const b = blazonFor(seed); + // rule of tincture: exactly one of field/charge is a metal (colour on metal, or metal on colour) + expect(metals.includes(b.fieldTincture)).not.toEqual(metals.includes(b.chargeTincture)); + } + }); + it('produces distinct arms for distinct seeds', () => { + const a = blazonFor('security-gildi'); + const b = blazonFor('release-captains-gildi'); + expect(JSON.stringify(a)).not.toEqual(JSON.stringify(b)); + }); +}); diff --git a/plugins/gildi/src/crest/blazon.ts b/plugins/gildi/src/crest/blazon.ts new file mode 100644 index 0000000..676a05b --- /dev/null +++ b/plugins/gildi/src/crest/blazon.ts @@ -0,0 +1,43 @@ +import { hashSeed } from './hash'; + +export const COLOURS = { gules: '#a83a3a', azure: '#2f5fa0', vert: '#3a7a4a', sable: '#2b2b30', purpure: '#6b3a6b' } as const; +export const METALS = { or: '#d9b23a', argent: '#dcdce0' } as const; +export type Tincture = keyof typeof COLOURS | keyof typeof METALS; +export type Division = 'plain' | 'perPale' | 'perFess' | 'perBend'; +export type Charge = 'key' | 'chevron' | 'mullet' | 'roundel' | 'cross'; +export interface Blazon { + division: Division; + fieldTincture: Tincture; // the field (may be two tinctures for divided fields; second derives) + fieldTincture2: Tincture; + chargeTincture: Tincture; + charge: Charge; + fieldIsColour: boolean; // true → charge is a metal (rule of tincture) +} + +const COLOUR_KEYS = Object.keys(COLOURS) as (keyof typeof COLOURS)[]; +const METAL_KEYS = Object.keys(METALS) as (keyof typeof METALS)[]; +const DIVISIONS: Division[] = ['plain', 'perPale', 'perFess', 'perBend']; +const CHARGES: Charge[] = ['key', 'chevron', 'mullet', 'roundel', 'cross']; + +export function blazonFor(seed: string): Blazon { + const h = hashSeed(seed); + // Draw independent choices from different byte-lanes of the hash. + const fieldIsColour = (h & 1) === 0; + const colour = COLOUR_KEYS[(h >>> 1) % COLOUR_KEYS.length]; + const colour2 = COLOUR_KEYS[(h >>> 4) % COLOUR_KEYS.length]; + const metal = METAL_KEYS[(h >>> 7) % METAL_KEYS.length]; + const division = DIVISIONS[(h >>> 9) % DIVISIONS.length]; + const charge = CHARGES[(h >>> 12) % CHARGES.length]; + return { + division, + fieldTincture: fieldIsColour ? colour : metal, + fieldTincture2: fieldIsColour ? colour2 : metal, + chargeTincture: fieldIsColour ? metal : colour, + charge, + fieldIsColour, + }; +} + +export function tinctureHex(t: Tincture): string { + return (COLOURS as Record)[t] ?? (METALS as Record)[t]; +} diff --git a/plugins/gildi/src/crest/hash.ts b/plugins/gildi/src/crest/hash.ts new file mode 100644 index 0000000..829e9db --- /dev/null +++ b/plugins/gildi/src/crest/hash.ts @@ -0,0 +1,9 @@ +// FNV-1a 32-bit — stable across runs/machines (no Math.random / Date). +export function hashSeed(seed: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} diff --git a/plugins/gildi/src/crest/index.ts b/plugins/gildi/src/crest/index.ts new file mode 100644 index 0000000..f35095b --- /dev/null +++ b/plugins/gildi/src/crest/index.ts @@ -0,0 +1,3 @@ +export { Crest } from './Crest'; +export { blazonFor, tinctureHex, COLOURS, METALS } from './blazon'; +export type { Blazon, Tincture, Charge, Division } from './blazon'; From d08ebb454ea7ecf8a046e3f0e67cfad40ed11038 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 16:21:56 -0400 Subject: [PATCH 04/16] feat(gildi): Guilds section - typed catalog search + heraldic guild cards Query kind:Group spec.type:guild (joined to owned type:practice Components), render a responsive grid of curated guild cards (crest + name + description + practice/aspect chips) linking to each guild's entity page. First frontend catalog-querying in the repo. Also hardens the crest clipPath id to useId for unique rendering across the grid, and adds a default case to the crest's charge-shape switch to satisfy the repo-wide lint gate. Updates the existing GuildHallPage smoke test to mock catalogApiRef now that the page mounts the guilds section. ws test + ws lint green; browser visual check of the rendered cards pending human confirmation. Co-Authored-By: Claude Opus 4.8 --- plugins/gildi/package.json | 5 +- .../gildi/src/components/GuildHallPage.tsx | 4 +- plugins/gildi/src/crest/Crest.tsx | 4 +- plugins/gildi/src/guilds/GuildCard.tsx | 30 +++++++++++ .../gildi/src/guilds/GuildsSection.test.tsx | 27 ++++++++++ plugins/gildi/src/guilds/GuildsSection.tsx | 15 ++++++ plugins/gildi/src/guilds/useGuilds.ts | 53 +++++++++++++++++++ plugins/gildi/src/plugin.test.tsx | 11 +++- 8 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 plugins/gildi/src/guilds/GuildCard.tsx create mode 100644 plugins/gildi/src/guilds/GuildsSection.test.tsx create mode 100644 plugins/gildi/src/guilds/GuildsSection.tsx create mode 100644 plugins/gildi/src/guilds/useGuilds.ts diff --git a/plugins/gildi/package.json b/plugins/gildi/package.json index 4b4ba68..d80fbc5 100644 --- a/plugins/gildi/package.json +++ b/plugins/gildi/package.json @@ -26,12 +26,15 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@backstage/catalog-model": "^1.9.0", "@backstage/core-components": "^0.18.11", + "@backstage/core-plugin-api": "^1.12.7", "@backstage/frontend-plugin-api": "^0.17.2", "@backstage/plugin-catalog-react": "^3.1.0", "@backstage/ui": "^0.16.0", "@material-ui/icons": "^4.9.1", - "react-router-dom": "^6.30.2" + "react-router-dom": "^6.30.2", + "react-use": "^17.6.1" }, "peerDependencies": { "react": "^17 || ^18", diff --git a/plugins/gildi/src/components/GuildHallPage.tsx b/plugins/gildi/src/components/GuildHallPage.tsx index 9aacbaf..e5029bf 100644 --- a/plugins/gildi/src/components/GuildHallPage.tsx +++ b/plugins/gildi/src/components/GuildHallPage.tsx @@ -1,11 +1,13 @@ import { Content, Header, Page } from '@backstage/core-components'; +import { GuildsSection } from '../guilds/GuildsSection'; export function GuildHallPage() { return (
-

The Guild Hall is taking shape. Guilds, drives, chronicle, and actions arrive next.

+

Guilds

+
); diff --git a/plugins/gildi/src/crest/Crest.tsx b/plugins/gildi/src/crest/Crest.tsx index 4261cf3..882251b 100644 --- a/plugins/gildi/src/crest/Crest.tsx +++ b/plugins/gildi/src/crest/Crest.tsx @@ -1,3 +1,4 @@ +import { useId } from 'react'; import { blazonFor, tinctureHex, Charge } from './blazon'; const SHIELD = 'M9 7 L51 7 L51 33 Q51 52 30 63 Q9 52 9 33 Z'; @@ -9,13 +10,14 @@ function ChargeShape({ charge, fill }: { charge: Charge; fill: string }) { case 'mullet': return ; case 'roundel': return ; case 'cross': return ; + default: return null; } } export function Crest({ seed, size = 44, title }: { seed: string; size?: number; title?: string }) { + const id = useId(); if (!seed) return null; const b = blazonFor(seed); - const id = `gildi-crest-${seed.replace(/[^a-z0-9]/gi, '')}`; const f1 = tinctureHex(b.fieldTincture); const f2 = tinctureHex(b.fieldTincture2); const charge = tinctureHex(b.chargeTincture); diff --git a/plugins/gildi/src/guilds/GuildCard.tsx b/plugins/gildi/src/guilds/GuildCard.tsx new file mode 100644 index 0000000..48ade4f --- /dev/null +++ b/plugins/gildi/src/guilds/GuildCard.tsx @@ -0,0 +1,30 @@ +import type { CSSProperties } from 'react'; +import { Link } from '@backstage/core-components'; +import { Crest } from '../crest'; +import type { GuildView } from './useGuilds'; + +export function GuildCard({ guild }: { guild: GuildView }) { + const aspects = Array.from(new Set([ + ...guild.stewardAspects, + ...guild.practices.map(p => p.aspect).filter(Boolean) as string[], + ])); + return ( + +
+ +
+
{guild.title}
+ {guild.description &&
{guild.description}
} +
+ {guild.practices.map(p => ({p.title}))} + {aspects.map(a => ({a} aspect))} +
+
+
+ + ); +} + +function chip(c: string): CSSProperties { + return { fontSize: 10.5, padding: '2px 8px', borderRadius: 20, border: `1px solid ${c}80`, background: `${c}1a` }; +} diff --git a/plugins/gildi/src/guilds/GuildsSection.test.tsx b/plugins/gildi/src/guilds/GuildsSection.test.tsx new file mode 100644 index 0000000..2e3c707 --- /dev/null +++ b/plugins/gildi/src/guilds/GuildsSection.test.tsx @@ -0,0 +1,27 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { GuildsSection } from './GuildsSection'; + +const catalogApi = { + getEntities: async ({ filter }: any) => { + if (filter['spec.type'] === 'guild') { + return { items: [{ apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: 'security-gildi', title: 'Security guild', description: 'Keeps things safe.', annotations: { 'siliconsaga.org/stewards': 'aspect:security' } }, spec: { type: 'guild' } }] }; + } + return { items: [{ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'security-practice', title: 'Security practice', annotations: { 'siliconsaga.org/aspect': 'security' } }, spec: { type: 'practice', owner: 'group:default/security-gildi' } }] }; + }, +}; + +describe('GuildsSection', () => { + it('renders a curated guild card with name, description, practice and aspect', async () => { + await renderInTestApp( + + + , + ); + expect(await screen.findByText('Security guild')).toBeInTheDocument(); + expect(screen.getByText('Keeps things safe.')).toBeInTheDocument(); + expect(screen.getByText('Security practice')).toBeInTheDocument(); + expect(screen.getByText('security aspect')).toBeInTheDocument(); + }); +}); diff --git a/plugins/gildi/src/guilds/GuildsSection.tsx b/plugins/gildi/src/guilds/GuildsSection.tsx new file mode 100644 index 0000000..6262508 --- /dev/null +++ b/plugins/gildi/src/guilds/GuildsSection.tsx @@ -0,0 +1,15 @@ +import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useGuilds } from './useGuilds'; +import { GuildCard } from './GuildCard'; + +export function GuildsSection() { + const { guilds, loading, error } = useGuilds(); + if (loading) return ; + if (error) return ; + if (guilds.length === 0) return

No guilds yet — define a Group with spec.type: guild.

; + return ( +
+ {guilds.map(g => ())} +
+ ); +} diff --git a/plugins/gildi/src/guilds/useGuilds.ts b/plugins/gildi/src/guilds/useGuilds.ts new file mode 100644 index 0000000..bc7c91f --- /dev/null +++ b/plugins/gildi/src/guilds/useGuilds.ts @@ -0,0 +1,53 @@ +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import type { Entity } from '@backstage/catalog-model'; + +export interface GuildView { + name: string; + title: string; + description?: string; + entityRef: string; // e.g. group:default/security-gildi + stewardAspects: string[]; // from siliconsaga.org/stewards: 'aspect:security' + practices: { name: string; title: string; aspect?: string }[]; +} + +const STEWARDS = 'siliconsaga.org/stewards'; +const ASPECT = 'siliconsaga.org/aspect'; + +export function useGuilds() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const [guildsRes, practicesRes] = await Promise.all([ + catalog.getEntities({ filter: { kind: 'Group', 'spec.type': 'guild' } }), + catalog.getEntities({ filter: { kind: 'Component', 'spec.type': 'practice' } }), + ]); + const practicesByOwner = new Map(); + for (const p of practicesRes.items) { + const owner = (p.spec?.owner as string) ?? ''; + const key = owner.includes('/') ? owner : `group:default/${owner.replace(/^group:/, '')}`; + practicesByOwner.set(key, [...(practicesByOwner.get(key) ?? []), p]); + } + const guilds: GuildView[] = guildsRes.items.map(g => { + const ref = `group:default/${g.metadata.name}`; + const stewards = (g.metadata.annotations?.[STEWARDS] ?? '') + .split(',').map(s => s.trim()).filter(Boolean) + .filter(s => s.startsWith('aspect:')).map(s => s.slice('aspect:'.length)); + const practices = (practicesByOwner.get(ref) ?? []).map(p => ({ + name: p.metadata.name, + title: p.metadata.title ?? p.metadata.name, + aspect: p.metadata.annotations?.[ASPECT], + })); + return { + name: g.metadata.name, + title: g.metadata.title ?? g.metadata.name, + description: g.metadata.description, + entityRef: ref, + stewardAspects: stewards, + practices, + }; + }); + return guilds; + }, [catalog]); + return { guilds: state.value ?? [], loading: state.loading, error: state.error }; +} diff --git a/plugins/gildi/src/plugin.test.tsx b/plugins/gildi/src/plugin.test.tsx index eea0b81..e308b47 100644 --- a/plugins/gildi/src/plugin.test.tsx +++ b/plugins/gildi/src/plugin.test.tsx @@ -1,10 +1,19 @@ import { screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { GuildHallPage } from './components/GuildHallPage'; +// GuildHallPage mounts GuildsSection, which queries the catalog — supply an +// empty mock so this header-level smoke test doesn't depend on guild data. +const emptyCatalogApi = { + getEntities: async () => ({ items: [] }), +}; + describe('GuildHallPage', () => { it('renders the Guild Hall header', async () => { - await renderInTestApp(); + await renderInTestApp(, { + apis: [[catalogApiRef, emptyCatalogApi]], + }); expect(await screen.findByText('Guild Hall')).toBeInTheDocument(); }); }); From e286f956caf50bdfdc5799f571e0826aa6c0507e Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 21:51:54 -0400 Subject: [PATCH 05/16] feat(gildi): theme-aware guild cards + seed display titles Add display titles to the guild Groups + security-practice so cards read "Security guild" / "Release Captains" / "Security practice" not raw kebab ids. Refactor GuildCard to theme-aware MUI Typography/Chip + EntityRefLink (consuming GuildView.entityRef) instead of fixed inline pixel styles, fixing the small-font look and dropping the hand-built entity path. Adds @material-ui/core as an explicit gildi plugin dependency (was only hoisted transitively before). Updates GuildsSection.test.tsx to bind entityRouteRef via mountedRoutes, which EntityRefLink now requires under renderInTestApp. ws test + ws lint green; browser re-check pending. Co-Authored-By: Claude Opus 4.8 --- examples/mock-org/org.yaml | 2 + .../repos/security-aspect/catalog-info.yaml | 1 + plugins/gildi/package.json | 1 + plugins/gildi/src/guilds/GuildCard.tsx | 38 +++++++++++-------- .../gildi/src/guilds/GuildsSection.test.tsx | 3 +- 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/examples/mock-org/org.yaml b/examples/mock-org/org.yaml index d073a85..4f7c2c2 100644 --- a/examples/mock-org/org.yaml +++ b/examples/mock-org/org.yaml @@ -62,6 +62,7 @@ apiVersion: backstage.io/v1alpha1 kind: Group metadata: name: security-gildi + title: Security guild description: The Security guild — stewards the security aspect (aspect-aligned gildi) annotations: siliconsaga.org/stewards: 'aspect:security' @@ -79,6 +80,7 @@ apiVersion: backstage.io/v1alpha1 kind: Group metadata: name: release-captains-gildi + title: Release Captains description: The Release Captains guild — stewards the release-captain craft (craft-aligned gildi) annotations: siliconsaga.org/stewards: 'craft:release-captain' diff --git a/examples/mock-org/repos/security-aspect/catalog-info.yaml b/examples/mock-org/repos/security-aspect/catalog-info.yaml index a4cfc34..8103224 100644 --- a/examples/mock-org/repos/security-aspect/catalog-info.yaml +++ b/examples/mock-org/repos/security-aspect/catalog-info.yaml @@ -6,6 +6,7 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: name: security-practice + title: Security practice description: 'The Security practice: its aspect module — standard, paved road, adoption templates, and remediation vísar' annotations: siliconsaga.org/aspect: 'security' diff --git a/plugins/gildi/package.json b/plugins/gildi/package.json index d80fbc5..3119249 100644 --- a/plugins/gildi/package.json +++ b/plugins/gildi/package.json @@ -32,6 +32,7 @@ "@backstage/frontend-plugin-api": "^0.17.2", "@backstage/plugin-catalog-react": "^3.1.0", "@backstage/ui": "^0.16.0", + "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "react-router-dom": "^6.30.2", "react-use": "^17.6.1" diff --git a/plugins/gildi/src/guilds/GuildCard.tsx b/plugins/gildi/src/guilds/GuildCard.tsx index 48ade4f..1889b84 100644 --- a/plugins/gildi/src/guilds/GuildCard.tsx +++ b/plugins/gildi/src/guilds/GuildCard.tsx @@ -1,5 +1,6 @@ -import type { CSSProperties } from 'react'; -import { Link } from '@backstage/core-components'; +import { Chip, Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core-components'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; import { Crest } from '../crest'; import type { GuildView } from './useGuilds'; @@ -9,22 +10,29 @@ export function GuildCard({ guild }: { guild: GuildView }) { ...guild.practices.map(p => p.aspect).filter(Boolean) as string[], ])); return ( - -
- + + +
-
{guild.title}
- {guild.description &&
{guild.description}
} + {guild.title} + {guild.description && ( + + {guild.description} + + )}
- {guild.practices.map(p => ({p.title}))} - {aspects.map(a => ({a} aspect))} + {guild.practices.map(p => ( + + ))} + {aspects.map(a => ( + + ))}
-
- + + ); } - -function chip(c: string): CSSProperties { - return { fontSize: 10.5, padding: '2px 8px', borderRadius: 20, border: `1px solid ${c}80`, background: `${c}1a` }; -} diff --git a/plugins/gildi/src/guilds/GuildsSection.test.tsx b/plugins/gildi/src/guilds/GuildsSection.test.tsx index 2e3c707..d599dbc 100644 --- a/plugins/gildi/src/guilds/GuildsSection.test.tsx +++ b/plugins/gildi/src/guilds/GuildsSection.test.tsx @@ -1,6 +1,6 @@ import { screen } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; import { GuildsSection } from './GuildsSection'; const catalogApi = { @@ -18,6 +18,7 @@ describe('GuildsSection', () => { , + { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef } }, ); expect(await screen.findByText('Security guild')).toBeInTheDocument(); expect(screen.getByText('Keeps things safe.')).toBeInTheDocument(); From f89e215b8a69dd9b1d87bdb853f80955b98a7911 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 22:17:11 -0400 Subject: [PATCH 06/16] =?UTF-8?q?docs(gildi):=20add=20Plan=203=20=E2=80=94?= =?UTF-8?q?=20full=20page=20shell=20(drives,=20chronicle,=20actions,=20lay?= =?UTF-8?q?out)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice, batched onto this branch per Cervator (conserve PR review quota, and the page reads better whole): the Drives band, the Chronicle rail (recent Sagas), the Actions panel (scaffolder links + a light charter-a-practice dogfood template), and the two-zone layout composing all five sections. Each new section mirrors the reviewed Guilds pattern (useX hook + XCard InfoCard/EntityRefLink/ Crest/Typography + XSection states). Grounded in the seed's Cycle/Saga shapes. Deferred (noted): typed+colored chips, front-matter saga previews, skald avatars. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-21-gildi-full-page-shell-plan.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/plans/2026-07-21-gildi-full-page-shell-plan.md diff --git a/docs/plans/2026-07-21-gildi-full-page-shell-plan.md b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md new file mode 100644 index 0000000..b0a7347 --- /dev/null +++ b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md @@ -0,0 +1,240 @@ +# Guild Hall hub (`gildi`) — Plan 3: Full page shell + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Flesh out the Guild Hall into its full designed page — add the **Drives band**, the **Chronicle** (recent Sagas), the **Actions** (scaffolder links + a light dogfood), and arrange all five sections into the two-zone layout. Batched onto `feat/gildi-crest-guilds` for one PR. + +**Architecture:** Each new section mirrors the proven Guilds pattern exactly — a `useX` hook (`catalogApiRef.getEntities` via `useApi` + `useAsync`) → an `XCard` (`InfoCard`/`EntityRefLink`/`Crest`/`Typography`/`Chip`, per `plugins/gildi/src/guilds/GuildCard.tsx`) → an `XSection` with loading/empty/error (per `GuildsSection.tsx`). A final layout task composes them: Intro → Drives band (full width, bounded) → an MUI `Grid` with a wide **Guilds** column and a **rail** (Actions + Chronicle). + +**Tech Stack:** as Plan 2 — new frontend system, `@backstage/plugin-catalog-react` (`catalogApiRef`, `EntityRefLink`), `@backstage/core-components` (`InfoCard`, `Progress`, `ResponseErrorPanel`, `Link`), `@material-ui/core` (`Grid`, `Typography`, `Chip`), the `Crest` from Plan 2. + +## Global Constraints + +- Node 22||24; verify via `ws test leidangr` (`make test test-app`) + `ws lint leidangr` (`make lint tsc`) — both green. One shell command per Bash call; no raw git commit/push; commit via `ws commit`. +- **Reuse the established pattern** — new hooks/cards/sections should look like their Guilds siblings (`plugins/gildi/src/guilds/*`). Curated cards, theme-aware Typography/Chip, NOT metadata dumps. +- **Identity-mark rule** (design §4): a card leads with the relevant **guild crest** when a guild is its actor/subject; actions lead with a glyph. Drives → the owner guild's crest; Sagas → the guild found in `spec.touches` (if any), else the skald. +- Seed Cycle/Saga shapes (verified in `examples/mock-org/cycles.yaml`): `Cycle` `spec.{type,timeframe:{start,end},of,owner}`; `Saga` `spec.{skald(User ref),timeframe,touches[],owner}` + `siliconsaga.org/saga-doc` annotation + `metadata.description`. +- Design source: `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` §3 (layout), §4 (cards). +- **Deferred (note, do NOT build here):** typed + colored tag chips with consistent casing (wait until tags carry structure); front-matter-fetched Saga previews (use `metadata.description` for now); skald avatars (use a byline for now); owner-ref join test-coverage hardening; the guilds↔chronicle wide/rail swap being user-configurable. + +--- + +### Task 1: Drives band + +**Files:** Create `plugins/gildi/src/drives/useDrives.ts`, `DriveCard.tsx`, `DrivesBand.tsx`, `DrivesBand.test.tsx`. + +**Interfaces:** Produces `` (mounted by Task 4). + +- [ ] **Step 1: `useDrives.ts`** — query campaign Cycles (default `spec.type: drive`), shape a `DriveView`: +```ts +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +export interface DriveView { + name: string; title: string; description?: string; + entityRef: string; // cycle:default/ + ownerGuildName?: string; // for the crest, when owner is a guild Group + start?: string; end?: string; +} + +export function useDrives() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const res = await catalog.getEntities({ filter: { kind: 'Cycle', 'spec.type': 'drive' } }); + return res.items.map(c => { + const owner = (c.spec?.owner as string) ?? ''; + const ownerName = owner.startsWith('group:') ? owner.split('/').pop() : undefined; + const tf = (c.spec?.timeframe as { start?: string; end?: string }) ?? {}; + return { + name: c.metadata.name, + title: c.metadata.title ?? c.metadata.name, + description: c.metadata.description, + entityRef: `cycle:default/${c.metadata.name}`, + ownerGuildName: ownerName, + start: tf.start, end: tf.end, + } as DriveView; + }); + }, [catalog]); + return { drives: state.value ?? [], loading: state.loading, error: state.error }; +} +``` + +- [ ] **Step 2: `DriveCard.tsx`** — mirror `GuildCard` (InfoCard + EntityRefLink + Crest + Typography); show title, description, and a timeframe line; lead with the owner guild's crest when present: +```tsx +import { Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core-components'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { Crest } from '../crest'; +import type { DriveView } from './useDrives'; + +export function DriveCard({ drive }: { drive: DriveView }) { + return ( + + + {drive.ownerGuildName && } +
+ Drive + {drive.title} + {drive.description && {drive.description}} + {(drive.start || drive.end) && {drive.start} → {drive.end}} +
+
+
+ ); +} +``` + +- [ ] **Step 3: `DrivesBand.tsx`** — bounded band; a responsive row (MUI `Grid` container), loading/empty/error like `GuildsSection`. Heading "Active & upcoming drives". Empty state: a muted "No active drives." Mirror `GuildsSection.tsx` structure (Progress / ResponseErrorPanel / grid). + +- [ ] **Step 4: `DrivesBand.test.tsx`** — mirror `GuildsSection.test.tsx`: `TestApiProvider` mocking `catalogApiRef.getEntities` to return one `drive` Cycle (owner `group:default/security-gildi`, a timeframe, a description); assert the title + description render. Bind `entityRouteRef` via `mountedRoutes` (as the guilds test does, since `EntityRefLink` needs it). + +- [ ] **Step 5: Verify** `ws test leidangr` + `ws lint leidangr` green. **Step 6: Commit** `.commits/gildi-drives.md` (`add: plugins/gildi/src/drives`), `feat(gildi): Drives band`. + +--- + +### Task 2: Chronicle (recent Sagas) + +**Files:** Create `plugins/gildi/src/chronicle/useSagas.ts`, `SagaCard.tsx`, `ChronicleRail.tsx`, `ChronicleRail.test.tsx`. + +**Interfaces:** Produces `` (mounted by Task 4). + +- [ ] **Step 1: `useSagas.ts`** — query `kind: Saga`; shape a `SagaView`; pull the involved guild from `spec.touches` (a `group:*` ref, prefer one ending `-gildi`) and the skald ref; sort by `timeframe.end` descending: +```ts +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +export interface SagaView { + name: string; title: string; description?: string; + entityRef: string; // saga:default/ + skaldRef?: string; // user:default/ + guildName?: string; // touched guild for the crest + end?: string; +} + +export function useSagas() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const res = await catalog.getEntities({ filter: { kind: 'Saga' } }); + const views = res.items.map(s => { + const touches = (s.spec?.touches as string[]) ?? []; + const guildRef = touches.find(t => t.startsWith('group:') && t.includes('gildi')) ?? touches.find(t => t.startsWith('group:')); + const tf = (s.spec?.timeframe as { end?: string }) ?? {}; + return { + name: s.metadata.name, + title: s.metadata.title ?? s.metadata.name, + description: s.metadata.description, + entityRef: `saga:default/${s.metadata.name}`, + skaldRef: s.spec?.skald as string | undefined, + guildName: guildRef ? guildRef.split('/').pop() : undefined, + end: tf.end, + } as SagaView; + }); + return views.sort((a, b) => (b.end ?? '').localeCompare(a.end ?? '')); + }, [catalog]); + return { sagas: state.value ?? [], loading: state.loading, error: state.error }; +} +``` + +- [ ] **Step 2: `SagaCard.tsx`** — compact preview: crest (touched guild) or nothing; title; a one-line description; a byline "by " via `EntityRefLink` to the skald User; a "Read →" `EntityRefLink` to the Saga entity. Mirror `DriveCard` styling; keep it compact (it lives in the rail). + +- [ ] **Step 3: `ChronicleRail.tsx`** — heading "Recent chronicle"; render the (already-sorted) sagas as a vertical stack of `SagaCard`s; loading/empty/error. Optionally cap at a prop `max` (default 4) with a note if more exist. Mirror `GuildsSection` states. + +- [ ] **Step 4: `ChronicleRail.test.tsx`** — mock two Sagas (different `timeframe.end`); assert both titles render and the newer sorts first; skald byline present. `mountedRoutes` for the entity refs. + +- [ ] **Step 5: Verify green. Step 6: Commit** `.commits/gildi-chronicle.md` (`add: plugins/gildi/src/chronicle`), `feat(gildi): Chronicle rail — recent sagas`. + +--- + +### Task 3: Actions + a light `guildhall` dogfood + +**Files:** Create `plugins/gildi/src/actions/useActions.ts`, `ActionCard.tsx`, `ActionsPanel.tsx`, `ActionsPanel.test.tsx`; seed `examples/mock-org/guildhall/actions/charter-practice.template.yaml` (+ register in `app-config.yaml`). + +**Interfaces:** Produces `` (mounted by Task 4). + +- [ ] **Step 1: Seed a dogfood scaffolder Template** (`examples/mock-org/guildhall/actions/charter-practice.template.yaml`) — a mock Template tagged `guild-hall` whose steps only `debug:log` (no real scaffolding), mirroring the existing `repos/security-aspect/template.yaml` shape: +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: charter-a-practice + title: Charter a practice + description: Stand up a new practice and its guild — the Guild Hall dogfooding its own model. + tags: [guild-hall] +spec: + type: guildhall-action + owner: group:default/team-devex + parameters: + - title: New practice + properties: + practice: { title: Practice name, type: string } + steps: + - id: log + name: Plan + action: debug:log + input: + message: 'Would charter the ${{ parameters.practice }} practice and stand up its guild.' +``` +Register it in `app-config.yaml` `catalog.locations` (allow `[Template]`), alongside the existing security-aspect template entry. + +- [ ] **Step 2: `useActions.ts`** — query `kind: Template` filtered by the convention tag `guild-hall` (`filter: { kind: 'Template', 'metadata.tags': 'guild-hall' }` — verify the tag-filter key against the installed catalog client; fall back to fetching all Templates and filtering `metadata.tags?.includes('guild-hall')` in JS if the filter key differs). Shape `ActionView { name, title, description?, createHref }` where `createHref = /create/templates/default/`. + +- [ ] **Step 3: `ActionCard.tsx`** — lead with a scaffolder glyph (a MUI icon, e.g. `AddCircleOutline`), title, one-line description, and a `Link` (from `@backstage/core-components`) to `createHref` labeled "Open in Create →". Compact, mirrors the card styling. + +- [ ] **Step 4: `ActionsPanel.tsx`** — heading "Actions"; stack of `ActionCard`s; loading/empty/error; empty state "No actions yet." Mirror `GuildsSection` states. + +- [ ] **Step 5: `ActionsPanel.test.tsx`** — mock `getEntities` returning the `charter-a-practice` Template; assert its title renders and the Create link points at `/create/templates/default/charter-a-practice`. + +- [ ] **Step 6: Verify** `ws test leidangr` + `ws lint leidangr` **and** `ws exec leidangr make smoke-catalog` (the new Template must ingest) green. **Step 7: Commit** `.commits/gildi-actions.md` (`add: plugins/gildi/src/actions`, `examples/mock-org/guildhall/actions/charter-practice.template.yaml`, `app-config.yaml`), `feat(gildi): Actions panel + charter-a-practice dogfood template`. + +--- + +### Task 4: Compose the two-zone layout + +**Files:** Modify `plugins/gildi/src/components/GuildHallPage.tsx`; update `plugins/gildi/src/plugin.test.tsx` if the page's mocked APIs change. + +- [ ] **Step 1: Restructure `GuildHallPage.tsx`** into the designed layout — Intro header → Drives band (full width, bounded) → an MUI `Grid` split: wide **Guilds** column + a **rail** (Actions above Chronicle): +```tsx +import { Grid } from '@material-ui/core'; +import { Content, Header, Page } from '@backstage/core-components'; +import { DrivesBand } from '../drives/DrivesBand'; +import { GuildsSection } from '../guilds/GuildsSection'; +import { ActionsPanel } from '../actions/ActionsPanel'; +import { ChronicleRail } from '../chronicle/ChronicleRail'; + +export function GuildHallPage() { + return ( + +
+ + + + +

Guilds

+ +
+ + + + +
+
+ + ); +} +``` +(Keep the `Guilds` heading inside its column; `DrivesBand`/`ActionsPanel`/`ChronicleRail` render their own headings.) + +- [ ] **Step 2: Fix `plugin.test.tsx`** — `GuildHallPage` now calls `getEntities` for guilds, practices, drives, sagas, and templates. Extend the mocked `catalogApiRef` so the smoke render test still passes (return `{ items: [] }` for the kinds it doesn't assert on, or minimal fixtures). Assert the page still renders the "Guild Hall" header. + +- [ ] **Step 3: Verify** `ws test leidangr` + `ws lint leidangr` green. **Step 4 (human-gated — do NOT boot):** visual — the full page shows the drives band up top, guilds in the wide column, actions + chronicle in the rail; note PENDING. **Step 5: Commit** `.commits/gildi-layout.md` (`add: plugins/gildi/src/components/GuildHallPage.tsx`, `plugins/gildi/src/plugin.test.tsx`), `feat(gildi): compose the two-zone Guild Hall layout`. + +--- + +## Self-Review + +- **Spec coverage:** Drives (§3/§4) → Task 1; Chronicle → Task 2; Actions + dogfood → Task 3; two-zone layout → Task 4. Deferred items explicitly listed in Global Constraints. +- **Placeholder scan:** each task carries concrete hook/card code + a test; the two verify-against-installed points (the Template tag-filter key; `useApi`/`TestApiProvider` sources — already resolved in Plan 2) have fallback notes. +- **Type consistency:** `DriveView`/`SagaView`/`ActionView` and their `useX`/`XCard`/`XSection` trios mirror the guilds trio; Task 4 mounts ``, ``, ``, `` by those exact names. +- **Pattern reuse:** every card is `InfoCard` + `Typography`/`Chip` + `EntityRefLink` + (crest where a guild leads), matching the reviewed `GuildCard`. From d426c3d2d7b48c74f4c7aeec7ab3d3d12495a9f2 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 22:19:48 -0400 Subject: [PATCH 07/16] feat(gildi): Drives band Query Cycle spec.type:drive, render a bounded "Active & upcoming drives" band of curated drive cards (owner-guild crest + title + description + timeframe) linking to the Cycle entity. Mirrors the reviewed Guilds section pattern. Not yet mounted in the page (layout task composes all sections). Co-Authored-By: Claude Opus 4.8 --- plugins/gildi/src/drives/DriveCard.tsx | 21 ++++++++++ plugins/gildi/src/drives/DrivesBand.test.tsx | 41 ++++++++++++++++++++ plugins/gildi/src/drives/DrivesBand.tsx | 18 +++++++++ plugins/gildi/src/drives/useDrives.ts | 31 +++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 plugins/gildi/src/drives/DriveCard.tsx create mode 100644 plugins/gildi/src/drives/DrivesBand.test.tsx create mode 100644 plugins/gildi/src/drives/DrivesBand.tsx create mode 100644 plugins/gildi/src/drives/useDrives.ts diff --git a/plugins/gildi/src/drives/DriveCard.tsx b/plugins/gildi/src/drives/DriveCard.tsx new file mode 100644 index 0000000..2860f32 --- /dev/null +++ b/plugins/gildi/src/drives/DriveCard.tsx @@ -0,0 +1,21 @@ +import { Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core-components'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { Crest } from '../crest'; +import type { DriveView } from './useDrives'; + +export function DriveCard({ drive }: { drive: DriveView }) { + return ( + + + {drive.ownerGuildName && } +
+ Drive + {drive.title} + {drive.description && {drive.description}} + {(drive.start || drive.end) && {drive.start} → {drive.end}} +
+
+
+ ); +} diff --git a/plugins/gildi/src/drives/DrivesBand.test.tsx b/plugins/gildi/src/drives/DrivesBand.test.tsx new file mode 100644 index 0000000..e17ce4e --- /dev/null +++ b/plugins/gildi/src/drives/DrivesBand.test.tsx @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { DrivesBand } from './DrivesBand'; + +const catalogApi = { + getEntities: async ({ filter }: any) => { + if (filter.kind === 'Cycle' && filter['spec.type'] === 'drive') { + return { + items: [{ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Cycle', + metadata: { + name: 'dependency-scanning-drive', + title: 'Dependency scanning drive', + description: 'Roll out automated dependency scanning across active repos.', + }, + spec: { + type: 'drive', + owner: 'group:default/security-gildi', + timeframe: { start: '2026-05-01', end: '2026-07-31' }, + }, + }], + }; + } + return { items: [] }; + }, +}; + +describe('DrivesBand', () => { + it('renders a curated drive card with title and description', async () => { + await renderInTestApp( + + + , + { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef } }, + ); + expect(await screen.findByText('Dependency scanning drive')).toBeInTheDocument(); + expect(screen.getByText('Roll out automated dependency scanning across active repos.')).toBeInTheDocument(); + }); +}); diff --git a/plugins/gildi/src/drives/DrivesBand.tsx b/plugins/gildi/src/drives/DrivesBand.tsx new file mode 100644 index 0000000..73370c6 --- /dev/null +++ b/plugins/gildi/src/drives/DrivesBand.tsx @@ -0,0 +1,18 @@ +import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useDrives } from './useDrives'; +import { DriveCard } from './DriveCard'; + +export function DrivesBand() { + const { drives, loading, error } = useDrives(); + if (loading) return ; + if (error) return ; + if (drives.length === 0) return

No active drives.

; + return ( +
+

Active & upcoming drives

+
+ {drives.map(d => ())} +
+
+ ); +} diff --git a/plugins/gildi/src/drives/useDrives.ts b/plugins/gildi/src/drives/useDrives.ts new file mode 100644 index 0000000..e915566 --- /dev/null +++ b/plugins/gildi/src/drives/useDrives.ts @@ -0,0 +1,31 @@ +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +export interface DriveView { + name: string; title: string; description?: string; + entityRef: string; // cycle:default/ + ownerGuildName?: string; // for the crest, when owner is a guild Group + start?: string; end?: string; +} + +export function useDrives() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const res = await catalog.getEntities({ filter: { kind: 'Cycle', 'spec.type': 'drive' } }); + return res.items.map(c => { + const owner = (c.spec?.owner as string) ?? ''; + const ownerName = owner.startsWith('group:') ? owner.split('/').pop() : undefined; + const tf = (c.spec?.timeframe as { start?: string; end?: string }) ?? {}; + return { + name: c.metadata.name, + title: c.metadata.title ?? c.metadata.name, + description: c.metadata.description, + entityRef: `cycle:default/${c.metadata.name}`, + ownerGuildName: ownerName, + start: tf.start, end: tf.end, + } as DriveView; + }); + }, [catalog]); + return { drives: state.value ?? [], loading: state.loading, error: state.error }; +} From 160c99b1aa8313a9ca3ac848fbdfd3f64f9880e5 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 22:24:24 -0400 Subject: [PATCH 08/16] feat(gildi): Chronicle rail - recent sagas Query kind:Saga (newest-first by timeframe.end), render a compact "Recent chronicle" rail of saga preview cards (touched-guild crest + title + one-line description + skald byline + Read link). Mirrors the reviewed Guilds/Drives pattern. Not yet mounted (layout task composes sections). Front-matter previews deferred - uses metadata.description for now. Co-Authored-By: Claude Opus 4.8 --- .../src/chronicle/ChronicleRail.test.tsx | 66 +++++++++++++++++++ plugins/gildi/src/chronicle/ChronicleRail.tsx | 25 +++++++ plugins/gildi/src/chronicle/SagaCard.tsx | 32 +++++++++ plugins/gildi/src/chronicle/useSagas.ts | 34 ++++++++++ 4 files changed, 157 insertions(+) create mode 100644 plugins/gildi/src/chronicle/ChronicleRail.test.tsx create mode 100644 plugins/gildi/src/chronicle/ChronicleRail.tsx create mode 100644 plugins/gildi/src/chronicle/SagaCard.tsx create mode 100644 plugins/gildi/src/chronicle/useSagas.ts diff --git a/plugins/gildi/src/chronicle/ChronicleRail.test.tsx b/plugins/gildi/src/chronicle/ChronicleRail.test.tsx new file mode 100644 index 0000000..8951d22 --- /dev/null +++ b/plugins/gildi/src/chronicle/ChronicleRail.test.tsx @@ -0,0 +1,66 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { ChronicleRail } from './ChronicleRail'; + +const catalogApi = { + getEntities: async ({ filter }: any) => { + if (filter.kind === 'Saga') { + return { + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Saga', + metadata: { + name: 'saga-tracking-2026-2', + title: 'Tracking saga, chapter 2', + description: 'The guild tracks down a lingering regression.', + }, + spec: { + skald: 'user:default/runa', + touches: ['group:default/security-gildi'], + timeframe: { end: '2026-06-30' }, + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Saga', + metadata: { + name: 'saga-dependency-scanning-drive', + title: 'The dependency scanning drive', + description: 'How the security guild rolled out automated scanning.', + }, + spec: { + skald: 'user:default/astrid', + touches: ['group:default/security-gildi'], + timeframe: { end: '2026-07-31' }, + }, + }, + ], + }; + } + return { items: [] }; + }, +}; + +describe('ChronicleRail', () => { + it('renders sagas newest-first with a skald byline', async () => { + await renderInTestApp( + + + , + { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef } }, + ); + + const newer = await screen.findByText('The dependency scanning drive'); + const older = screen.getByText('Tracking saga, chapter 2'); + expect(newer).toBeInTheDocument(); + expect(older).toBeInTheDocument(); + // newer should precede older in DOM order + // eslint-disable-next-line no-bitwise + expect(newer.compareDocumentPosition(older) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + expect(screen.getByText('astrid')).toBeInTheDocument(); + expect(screen.getByText('runa')).toBeInTheDocument(); + }); +}); diff --git a/plugins/gildi/src/chronicle/ChronicleRail.tsx b/plugins/gildi/src/chronicle/ChronicleRail.tsx new file mode 100644 index 0000000..74ce015 --- /dev/null +++ b/plugins/gildi/src/chronicle/ChronicleRail.tsx @@ -0,0 +1,25 @@ +import { Typography } from '@material-ui/core'; +import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useSagas } from './useSagas'; +import { SagaCard } from './SagaCard'; + +export function ChronicleRail({ max = 4 }: { max?: number }) { + const { sagas, loading, error } = useSagas(); + if (loading) return ; + if (error) return ; + if (sagas.length === 0) return

No sagas yet.

; + const shown = sagas.slice(0, max); + return ( +
+

Recent chronicle

+
+ {shown.map(s => ())} +
+ {sagas.length > shown.length && ( + + +{sagas.length - shown.length} more in the chronicle + + )} +
+ ); +} diff --git a/plugins/gildi/src/chronicle/SagaCard.tsx b/plugins/gildi/src/chronicle/SagaCard.tsx new file mode 100644 index 0000000..c755e52 --- /dev/null +++ b/plugins/gildi/src/chronicle/SagaCard.tsx @@ -0,0 +1,32 @@ +import { Typography } from '@material-ui/core'; +import { InfoCard } from '@backstage/core-components'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { Crest } from '../crest'; +import type { SagaView } from './useSagas'; + +export function SagaCard({ saga }: { saga: SagaView }) { + return ( + +
+ {saga.guildName && } +
+ Saga + {saga.title} + {saga.description && ( + + {saga.description} + + )} + {saga.skaldRef && ( + + by + + )} + + Read → + +
+
+
+ ); +} diff --git a/plugins/gildi/src/chronicle/useSagas.ts b/plugins/gildi/src/chronicle/useSagas.ts new file mode 100644 index 0000000..286a443 --- /dev/null +++ b/plugins/gildi/src/chronicle/useSagas.ts @@ -0,0 +1,34 @@ +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +export interface SagaView { + name: string; title: string; description?: string; + entityRef: string; // saga:default/ + skaldRef?: string; // user:default/ + guildName?: string; // touched guild for the crest + end?: string; +} + +export function useSagas() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + const res = await catalog.getEntities({ filter: { kind: 'Saga' } }); + const views = res.items.map(s => { + const touches = (s.spec?.touches as string[]) ?? []; + const guildRef = touches.find(t => t.startsWith('group:') && t.includes('gildi')) ?? touches.find(t => t.startsWith('group:')); + const tf = (s.spec?.timeframe as { end?: string }) ?? {}; + return { + name: s.metadata.name, + title: s.metadata.title ?? s.metadata.name, + description: s.metadata.description, + entityRef: `saga:default/${s.metadata.name}`, + skaldRef: s.spec?.skald as string | undefined, + guildName: guildRef ? guildRef.split('/').pop() : undefined, + end: tf.end, + } as SagaView; + }); + return views.sort((a, b) => (b.end ?? '').localeCompare(a.end ?? '')); + }, [catalog]); + return { sagas: state.value ?? [], loading: state.loading, error: state.error }; +} From f40d3aca9ca484a00f936b5dac40eba23d5e90e4 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 22:57:12 -0400 Subject: [PATCH 09/16] feat(gildi): Actions panel + charter-a-practice dogfood template Query scaffolder Templates tagged guild-hall, render an "Actions" panel of curated action cards (glyph + title + Create-page link). Seed a mock charter-a-practice Template (debug:log only) tagged guild-hall + register it in app-config, so the hub dogfoods its own model. Mirrors the reviewed section pattern. Not yet mounted (layout task). Co-Authored-By: Claude Opus 4.8 --- app-config.yaml | 7 ++++ .../actions/charter-practice.template.yaml | 20 +++++++++ plugins/gildi/src/actions/ActionCard.tsx | 26 ++++++++++++ .../gildi/src/actions/ActionsPanel.test.tsx | 41 +++++++++++++++++++ plugins/gildi/src/actions/ActionsPanel.tsx | 18 ++++++++ plugins/gildi/src/actions/useActions.ts | 31 ++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 examples/mock-org/guildhall/actions/charter-practice.template.yaml create mode 100644 plugins/gildi/src/actions/ActionCard.tsx create mode 100644 plugins/gildi/src/actions/ActionsPanel.test.tsx create mode 100644 plugins/gildi/src/actions/ActionsPanel.tsx create mode 100644 plugins/gildi/src/actions/useActions.ts diff --git a/app-config.yaml b/app-config.yaml index 22c0da0..b1ad686 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -164,6 +164,13 @@ catalog: target: ../../examples/mock-org/cycles.yaml rules: - allow: [Cycle, Saga] + # Guild Hall dogfood: a mock scaffolder Template tagged guild-hall so the + # Actions panel has something to render (the guildhall/ vocabularies + # above it are deliberately NOT ingested; this one is). + - type: file + target: ../../examples/mock-org/guildhall/actions/charter-practice.template.yaml + rules: + - allow: [Template] ## Uncomment these lines to add more example data # - type: url diff --git a/examples/mock-org/guildhall/actions/charter-practice.template.yaml b/examples/mock-org/guildhall/actions/charter-practice.template.yaml new file mode 100644 index 0000000..f44b62d --- /dev/null +++ b/examples/mock-org/guildhall/actions/charter-practice.template.yaml @@ -0,0 +1,20 @@ +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: charter-a-practice + title: Charter a practice + description: Stand up a new practice and its guild — the Guild Hall dogfooding its own model. + tags: [guild-hall] +spec: + type: guildhall-action + owner: group:default/team-devex + parameters: + - title: New practice + properties: + practice: { title: Practice name, type: string } + steps: + - id: log + name: Plan + action: debug:log + input: + message: 'Would charter the ${{ parameters.practice }} practice and stand up its guild.' diff --git a/plugins/gildi/src/actions/ActionCard.tsx b/plugins/gildi/src/actions/ActionCard.tsx new file mode 100644 index 0000000..1373b32 --- /dev/null +++ b/plugins/gildi/src/actions/ActionCard.tsx @@ -0,0 +1,26 @@ +import { Typography } from '@material-ui/core'; +import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import { InfoCard, Link } from '@backstage/core-components'; +import type { ActionView } from './useActions'; + +export function ActionCard({ action }: { action: ActionView }) { + return ( + +
+ +
+ Action + {action.title} + {action.description && ( + + {action.description} + + )} + + Open in Create → + +
+
+
+ ); +} diff --git a/plugins/gildi/src/actions/ActionsPanel.test.tsx b/plugins/gildi/src/actions/ActionsPanel.test.tsx new file mode 100644 index 0000000..3c08b4b --- /dev/null +++ b/plugins/gildi/src/actions/ActionsPanel.test.tsx @@ -0,0 +1,41 @@ +import { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { ActionsPanel } from './ActionsPanel'; + +const catalogApi = { + getEntities: async ({ filter }: any) => { + if (filter.kind === 'Template') { + return { + items: [ + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'charter-a-practice', + title: 'Charter a practice', + description: 'Stand up a new practice and its guild — the Guild Hall dogfooding its own model.', + tags: ['guild-hall'], + }, + spec: { type: 'guildhall-action', owner: 'group:default/team-devex' }, + }, + ], + }; + } + return { items: [] }; + }, +}; + +describe('ActionsPanel', () => { + it('renders a curated action card linking to its Create page', async () => { + await renderInTestApp( + + + , + ); + + expect(await screen.findByText('Charter a practice')).toBeInTheDocument(); + const link = screen.getByText('Open in Create →').closest('a'); + expect(link).toHaveAttribute('href', '/create/templates/default/charter-a-practice'); + }); +}); diff --git a/plugins/gildi/src/actions/ActionsPanel.tsx b/plugins/gildi/src/actions/ActionsPanel.tsx new file mode 100644 index 0000000..9bcb35d --- /dev/null +++ b/plugins/gildi/src/actions/ActionsPanel.tsx @@ -0,0 +1,18 @@ +import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { useActions } from './useActions'; +import { ActionCard } from './ActionCard'; + +export function ActionsPanel() { + const { actions, loading, error } = useActions(); + if (loading) return ; + if (error) return ; + if (actions.length === 0) return

No actions yet.

; + return ( +
+

Actions

+
+ {actions.map(a => ())} +
+
+ ); +} diff --git a/plugins/gildi/src/actions/useActions.ts b/plugins/gildi/src/actions/useActions.ts new file mode 100644 index 0000000..3ac85dc --- /dev/null +++ b/plugins/gildi/src/actions/useActions.ts @@ -0,0 +1,31 @@ +import useAsync from 'react-use/lib/useAsync'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +export interface ActionView { + name: string; + title: string; + description?: string; + createHref: string; // /create/templates/default/ +} + +const GUILD_HALL_TAG = 'guild-hall'; + +export function useActions() { + const catalog = useApi(catalogApiRef); + const state = useAsync(async () => { + // The `metadata.tags` filter key isn't reliably matched by the installed + // catalog client's getEntities filter — fetch all Templates and filter + // on the guild-hall tag in JS instead. + const res = await catalog.getEntities({ filter: { kind: 'Template' } }); + return res.items + .filter(t => (t.metadata.tags ?? []).includes(GUILD_HALL_TAG)) + .map(t => ({ + name: t.metadata.name, + title: t.metadata.title ?? t.metadata.name, + description: t.metadata.description, + createHref: `/create/templates/default/${t.metadata.name}`, + } as ActionView)); + }, [catalog]); + return { actions: state.value ?? [], loading: state.loading, error: state.error }; +} From 8f96bc5fc510fe54fcd36130bb177a793d090bb5 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 23:00:13 -0400 Subject: [PATCH 10/16] feat(gildi): compose the two-zone Guild Hall layout Compose the full Guild Hall page: intro header, a bounded Drives band up top, then a two-zone Grid - a wide Guilds column and a rail holding Actions above the Chronicle. Extend the page render test's catalog mock for the kinds all sections now query. Full-page browser visual check pending human confirmation. Co-Authored-By: Claude Opus 4.8 --- .../gildi/src/components/GuildHallPage.tsx | 17 ++++++++-- plugins/gildi/src/plugin.test.tsx | 34 ++++++++++++++----- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/plugins/gildi/src/components/GuildHallPage.tsx b/plugins/gildi/src/components/GuildHallPage.tsx index e5029bf..82697f5 100644 --- a/plugins/gildi/src/components/GuildHallPage.tsx +++ b/plugins/gildi/src/components/GuildHallPage.tsx @@ -1,13 +1,26 @@ +import { Grid } from '@material-ui/core'; import { Content, Header, Page } from '@backstage/core-components'; +import { DrivesBand } from '../drives/DrivesBand'; import { GuildsSection } from '../guilds/GuildsSection'; +import { ActionsPanel } from '../actions/ActionsPanel'; +import { ChronicleRail } from '../chronicle/ChronicleRail'; export function GuildHallPage() { return (
-

Guilds

- + + + +

Guilds

+ +
+ + + + +
); diff --git a/plugins/gildi/src/plugin.test.tsx b/plugins/gildi/src/plugin.test.tsx index e308b47..3f030a4 100644 --- a/plugins/gildi/src/plugin.test.tsx +++ b/plugins/gildi/src/plugin.test.tsx @@ -1,19 +1,37 @@ import { screen } from '@testing-library/react'; -import { renderInTestApp } from '@backstage/frontend-test-utils'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/frontend-test-utils'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; import { GuildHallPage } from './components/GuildHallPage'; -// GuildHallPage mounts GuildsSection, which queries the catalog — supply an -// empty mock so this header-level smoke test doesn't depend on guild data. +// GuildHallPage mounts GuildsSection, DrivesBand, ActionsPanel, and +// ChronicleRail, which together query the catalog for Group (guilds), +// Component (practices), Cycle (drives), Saga (chronicle), and Template +// (actions) entities. Dispatch on the filter's kind so this header-level +// smoke test doesn't depend on real catalog data — every kind resolves to +// an empty result set. const emptyCatalogApi = { - getEntities: async () => ({ items: [] }), + getEntities: async ({ filter }: any) => { + switch (filter?.kind) { + case 'Group': + case 'Component': + case 'Cycle': + case 'Saga': + case 'Template': + return { items: [] }; + default: + return { items: [] }; + } + }, }; describe('GuildHallPage', () => { it('renders the Guild Hall header', async () => { - await renderInTestApp(, { - apis: [[catalogApiRef, emptyCatalogApi]], - }); + await renderInTestApp( + + + , + { mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef } }, + ); expect(await screen.findByText('Guild Hall')).toBeInTheDocument(); }); }); From 1c364583bc0ed1cf82556745d98f388b83ddfb2f Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 23:23:21 -0400 Subject: [PATCH 11/16] feat(gildi): rail + layout polish - actions as buttons, lighter chronicle, top-aligned rail Move Drives+Guilds into a wide column and Actions+Chronicle into a top-aligned rail (fills the empty upper-right). Render Actions as small outlined buttons (Create-page links) rather than heavy cards, and Chronicle as compact saga rows rather than InfoCards - so the rail reads "a few buttons, then a list of sagas" per the design mockup. Tests updated. Co-Authored-By: Claude Opus 4.8 --- plugins/gildi/src/actions/ActionCard.tsx | 26 ---------- .../gildi/src/actions/ActionsPanel.test.tsx | 5 +- plugins/gildi/src/actions/ActionsPanel.tsx | 28 ++++++++--- plugins/gildi/src/chronicle/ChronicleRail.tsx | 13 +++-- plugins/gildi/src/chronicle/SagaCard.tsx | 48 +++++++++++-------- .../gildi/src/components/GuildHallPage.tsx | 6 +-- 6 files changed, 63 insertions(+), 63 deletions(-) delete mode 100644 plugins/gildi/src/actions/ActionCard.tsx diff --git a/plugins/gildi/src/actions/ActionCard.tsx b/plugins/gildi/src/actions/ActionCard.tsx deleted file mode 100644 index 1373b32..0000000 --- a/plugins/gildi/src/actions/ActionCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Typography } from '@material-ui/core'; -import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; -import { InfoCard, Link } from '@backstage/core-components'; -import type { ActionView } from './useActions'; - -export function ActionCard({ action }: { action: ActionView }) { - return ( - -
- -
- Action - {action.title} - {action.description && ( - - {action.description} - - )} - - Open in Create → - -
-
-
- ); -} diff --git a/plugins/gildi/src/actions/ActionsPanel.test.tsx b/plugins/gildi/src/actions/ActionsPanel.test.tsx index 3c08b4b..fcae1a4 100644 --- a/plugins/gildi/src/actions/ActionsPanel.test.tsx +++ b/plugins/gildi/src/actions/ActionsPanel.test.tsx @@ -27,15 +27,14 @@ const catalogApi = { }; describe('ActionsPanel', () => { - it('renders a curated action card linking to its Create page', async () => { + it('renders a curated action as a button linking to its Create page', async () => { await renderInTestApp( , ); - expect(await screen.findByText('Charter a practice')).toBeInTheDocument(); - const link = screen.getByText('Open in Create →').closest('a'); + const link = (await screen.findByText('Charter a practice')).closest('a'); expect(link).toHaveAttribute('href', '/create/templates/default/charter-a-practice'); }); }); diff --git a/plugins/gildi/src/actions/ActionsPanel.tsx b/plugins/gildi/src/actions/ActionsPanel.tsx index 9bcb35d..f7ffc29 100644 --- a/plugins/gildi/src/actions/ActionsPanel.tsx +++ b/plugins/gildi/src/actions/ActionsPanel.tsx @@ -1,18 +1,34 @@ +import { Button, Typography } from '@material-ui/core'; +import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; import { Progress, ResponseErrorPanel } from '@backstage/core-components'; import { useActions } from './useActions'; -import { ActionCard } from './ActionCard'; export function ActionsPanel() { const { actions, loading, error } = useActions(); if (loading) return ; if (error) return ; - if (actions.length === 0) return

No actions yet.

; return (
-

Actions

-
- {actions.map(a => ())} -
+ Actions + {actions.length === 0 ? ( + No actions yet. + ) : ( +
+ {actions.map(a => ( + + ))} +
+ )}
); } diff --git a/plugins/gildi/src/chronicle/ChronicleRail.tsx b/plugins/gildi/src/chronicle/ChronicleRail.tsx index 74ce015..38f47ac 100644 --- a/plugins/gildi/src/chronicle/ChronicleRail.tsx +++ b/plugins/gildi/src/chronicle/ChronicleRail.tsx @@ -1,4 +1,4 @@ -import { Typography } from '@material-ui/core'; +import { Divider, Typography } from '@material-ui/core'; import { Progress, ResponseErrorPanel } from '@backstage/core-components'; import { useSagas } from './useSagas'; import { SagaCard } from './SagaCard'; @@ -11,9 +11,14 @@ export function ChronicleRail({ max = 4 }: { max?: number }) { const shown = sagas.slice(0, max); return (
-

Recent chronicle

-
- {shown.map(s => ())} +

Recent chronicle

+
+ {shown.map((s, i) => ( +
+ {i > 0 && } + +
+ ))}
{sagas.length > shown.length && ( diff --git a/plugins/gildi/src/chronicle/SagaCard.tsx b/plugins/gildi/src/chronicle/SagaCard.tsx index c755e52..5538d4b 100644 --- a/plugins/gildi/src/chronicle/SagaCard.tsx +++ b/plugins/gildi/src/chronicle/SagaCard.tsx @@ -1,32 +1,38 @@ import { Typography } from '@material-ui/core'; -import { InfoCard } from '@backstage/core-components'; import { EntityRefLink } from '@backstage/plugin-catalog-react'; import { Crest } from '../crest'; import type { SagaView } from './useSagas'; export function SagaCard({ saga }: { saga: SagaView }) { return ( - -
- {saga.guildName && } -
- Saga - {saga.title} - {saga.description && ( - - {saga.description} - - )} - {saga.skaldRef && ( - - by - - )} - - Read → +
+ {saga.guildName && } +
+ + {saga.title} + + {saga.description && ( + + {saga.description} -
+ )} + {saga.skaldRef && ( + + by + + )}
- +
); } diff --git a/plugins/gildi/src/components/GuildHallPage.tsx b/plugins/gildi/src/components/GuildHallPage.tsx index 82697f5..7b59cb8 100644 --- a/plugins/gildi/src/components/GuildHallPage.tsx +++ b/plugins/gildi/src/components/GuildHallPage.tsx @@ -10,10 +10,10 @@ export function GuildHallPage() {
- - + -

Guilds

+ +

Guilds

From d909571025186152c688d1549c22eb1784660aeb Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 23:48:33 -0400 Subject: [PATCH 12/16] feat(seed): more guilds/drives + Cycle/Saga display titles for the Guild Hall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out the demo so the Guild Hall reads fuller: - Two more guilds: Platform (aspect-aligned, operational-readiness) and Incident Commanders (craft-aligned) — with egil/sigrid joining them. - A second drive Cycle: operational-readiness-drive (Platform guild). - Display `title`s on the Cycles and Sagas (they were showing raw kebab ids as the card/link text with the human title stuck in the description) — mock-org and the MTL soccer saga. Descriptions reworked into short previews. Verified: org.yaml + cycles.yaml parse and carry the new entities (yq). Co-Authored-By: Claude Opus 4.8 --- examples/mock-org/cycles.yaml | 25 ++++++++++++++++++---- examples/mock-org/org.yaml | 39 ++++++++++++++++++++++++++++++----- examples/mtl.yaml | 3 ++- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/examples/mock-org/cycles.yaml b/examples/mock-org/cycles.yaml index c46bc48..1e389ba 100644 --- a/examples/mock-org/cycles.yaml +++ b/examples/mock-org/cycles.yaml @@ -3,7 +3,7 @@ # season, on the software side of the two-family model. apiVersion: siliconsaga.org/v1alpha1 kind: Cycle -metadata: { name: tracking-2026-2, description: 'Parcel Tracking release 2026.2' } +metadata: { name: tracking-2026-2, title: 'Parcel Tracking 2026.2', description: 'Parcel Tracking release 2026.2' } spec: type: release timeframe: { start: '2026-04-01', end: '2026-06-30' } @@ -17,20 +17,36 @@ apiVersion: siliconsaga.org/v1alpha1 kind: Cycle metadata: name: dependency-scanning-drive - description: 'Drive: dependency scanning green on every service, Ravenline and Foxholm both (paved-road adoption push, module 1.4)' + title: Dependency scanning drive + description: 'Every service green on dependency scanning, Ravenline and Foxholm both — the paved-road adoption push for module 1.4.' spec: type: drive timeframe: { start: '2026-05-01', end: '2026-07-31' } of: group:default/security-gildi owner: group:default/security-gildi --- +# A second drive — the Platform guild's operational-readiness push — so the band +# shows more than one campaign at a time. +apiVersion: siliconsaga.org/v1alpha1 +kind: Cycle +metadata: + name: operational-readiness-drive + title: Operational readiness drive + description: 'Every production service with a linked rollback runbook and an SLO, before Q4.' +spec: + type: drive + timeframe: { start: '2026-07-01', end: '2026-09-30' } + of: group:default/platform-gildi + owner: group:default/platform-gildi +--- # The drive's own mid-run report — a Saga narrated by the drive's runner while # the Cycle is still open. Gives the adoption story a narrator in-catalog. apiVersion: siliconsaga.org/v1alpha1 kind: Saga metadata: name: saga-dependency-scanning-drive - description: 'The Saga of the Dependency Scanning Drive (mid-run report)' + title: The Dependency Scanning Drive + description: "Astrid's mid-run report — three of five services green, twelve days left; the paved road sells itself when the fix is smaller than the excuse." annotations: siliconsaga.org/saga-doc: ./sagas/dependency-scanning-drive.md spec: @@ -49,7 +65,8 @@ apiVersion: siliconsaga.org/v1alpha1 kind: Saga metadata: name: saga-tracking-2026-2 - description: 'The Saga of Parcel Tracking 2026.2' + title: Parcel Tracking 2026.2 — Retrospective + description: "Runa's release retrospective — what shipped, the May 14th incident, and what the next cycle carries forward." annotations: siliconsaga.org/saga-doc: ./sagas/tracking-2026-2.md spec: diff --git a/examples/mock-org/org.yaml b/examples/mock-org/org.yaml index 4f7c2c2..8470338 100644 --- a/examples/mock-org/org.yaml +++ b/examples/mock-org/org.yaml @@ -8,7 +8,7 @@ metadata: description: Ravenline — parcel-logistics SaaS (mock org, Guildhall demo) spec: type: organization - children: [rl-engineering, foxholm, security-gildi, release-captains-gildi] + children: [rl-engineering, foxholm, security-gildi, release-captains-gildi, platform-gildi, incident-commanders-gildi] --- apiVersion: backstage.io/v1alpha1 kind: Group @@ -89,6 +89,35 @@ spec: parent: ravenline children: [] --- +# Aspect-aligned gildi around the (steward-less until now) operational-readiness +# aspect — gives the guilds grid and the drives band a second concern to show. +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: platform-gildi + title: Platform guild + description: The Platform guild — stewards operational readiness (rollback runbooks, SLOs, on-call health) (aspect-aligned gildi) + annotations: + siliconsaga.org/stewards: 'aspect:operational-readiness' +spec: + type: guild + parent: ravenline + children: [] +--- +# Craft-aligned gildi around the incident-commander craft (guildhall/crafts.yaml). +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: incident-commanders-gildi + title: Incident Commanders + description: The Incident Commanders guild — stewards the incident-commander craft (craft-aligned gildi) + annotations: + siliconsaga.org/stewards: 'craft:incident-commander' +spec: + type: guild + parent: ravenline + children: [] +--- # People — membership carries both team and gildi affiliation via memberOf. # Skill profiles are NOT entity fields: see guildhall/skills.yaml. apiVersion: backstage.io/v1alpha1 @@ -113,13 +142,13 @@ spec: { memberOf: [team-tracking, security-gildi] } --- apiVersion: backstage.io/v1alpha1 kind: User -metadata: { name: sigrid, description: 'Shipping engineer' } -spec: { memberOf: [team-shipping] } +metadata: { name: sigrid, description: 'Shipping engineer, incident commander' } +spec: { memberOf: [team-shipping, incident-commanders-gildi] } --- apiVersion: backstage.io/v1alpha1 kind: User -metadata: { name: egil, description: 'Platform engineer' } -spec: { memberOf: [team-platform] } +metadata: { name: egil, description: 'Platform engineer, on-call lead' } +spec: { memberOf: [team-platform, platform-gildi] } --- apiVersion: backstage.io/v1alpha1 kind: User diff --git a/examples/mtl.yaml b/examples/mtl.yaml index c54174e..bc847aa 100644 --- a/examples/mtl.yaml +++ b/examples/mtl.yaml @@ -106,7 +106,8 @@ apiVersion: siliconsaga.org/v1alpha1 kind: Saga metadata: name: saga-soccer-2026-spring - description: 'The Saga of MTL Soccer, Spring 2026' + title: MTL Soccer, Spring 2026 + description: 'A youth soccer season, narrated — the community-side twin of the software retrospectives.' annotations: siliconsaga.org/saga-doc: ./sagas/soccer-2026-spring.md spec: From e79903c77f880a59fe94da6d0f0200e3bdb8f334 Mon Sep 17 00:00:00 2001 From: Cervator Date: Tue, 21 Jul 2026 23:55:11 -0400 Subject: [PATCH 13/16] feat(seed): two more Guild Hall action templates (establish-a-guild, start-a-drive) Complete the Actions set to the mockup's three buttons: add mock (debug:log) scaffolder Templates `establish-a-guild` (New guild) and `start-a-drive` (Start a drive), tagged guild-hall, registered in app-config alongside charter-a-practice. Real scaffolding is future work; these log their plan on the Create page. Co-Authored-By: Claude Opus 4.8 --- app-config.yaml | 4 ++ .../actions/guild-actions.template.yaml | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 examples/mock-org/guildhall/actions/guild-actions.template.yaml diff --git a/app-config.yaml b/app-config.yaml index b1ad686..e902ac8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -171,6 +171,10 @@ catalog: target: ../../examples/mock-org/guildhall/actions/charter-practice.template.yaml rules: - allow: [Template] + - type: file + target: ../../examples/mock-org/guildhall/actions/guild-actions.template.yaml + rules: + - allow: [Template] ## Uncomment these lines to add more example data # - type: url diff --git a/examples/mock-org/guildhall/actions/guild-actions.template.yaml b/examples/mock-org/guildhall/actions/guild-actions.template.yaml new file mode 100644 index 0000000..f0e9266 --- /dev/null +++ b/examples/mock-org/guildhall/actions/guild-actions.template.yaml @@ -0,0 +1,46 @@ +# Two more Guild Hall dogfood actions (mock — debug:log only), tagged guild-hall +# so the Actions panel shows the full set: New guild / Charter a practice / +# Start a drive. Real scaffolding is future work; these log their plan. +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: establish-a-guild + title: Establish a guild + description: Form a new guild (a typed Group) to steward a craft or an aspect — the Guild Hall dogfooding its own model. + tags: [guild-hall] +spec: + type: guildhall-action + owner: group:default/team-devex + parameters: + - title: New guild + properties: + guild: { title: Guild name, type: string } + stewards: { title: 'Stewards (aspect: or craft:)', type: string } + steps: + - id: log + name: Plan + action: debug:log + input: + message: 'Would establish the ${{ parameters.guild }} guild stewarding ${{ parameters.stewards }}.' +--- +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: start-a-drive + title: Start a drive + description: Launch a time-bound adoption drive (a Cycle) to push a practice across the fleet — the Guild Hall dogfooding its own model. + tags: [guild-hall] +spec: + type: guildhall-action + owner: group:default/team-devex + parameters: + - title: New drive + properties: + drive: { title: Drive name, type: string } + guild: { title: Owning guild, type: string } + steps: + - id: log + name: Plan + action: debug:log + input: + message: 'Would start the ${{ parameters.drive }} drive owned by ${{ parameters.guild }}.' From 6a45c0d2c33d3ad6be33f3b0410f44cba13edd4f Mon Sep 17 00:00:00 2001 From: Cervator Date: Thu, 23 Jul 2026 23:30:53 -0400 Subject: [PATCH 14/16] =?UTF-8?q?fix(gildi):=20address=20PR=20#12=20review?= =?UTF-8?q?=20=E2=80=94=20namespace=20refs,=20active-drive=20filter,=20sec?= =?UTF-8?q?tion=20headings,=20SPA=20action=20links,=20saga=20guild=20suffi?= =?UTF-8?q?x,=20doc/README=20accuracy,=20template=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit + Copilot on PR #12 (all valid): use stringifyEntityRef/namespace instead of hardcoded default across the hooks; filter drives to active/upcoming; keep section headings visible in loading/empty/error; pick the touched guild by -gildi suffix; SPA Link for action buttons (no full reload); required+minLength on the mock template params; refresh the README status; and align the plan docs with the as-built crest (useId, no monogram fallback) + saga selection. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-21-gildi-crest-and-guilds-plan.md | 7 +-- .../2026-07-21-gildi-full-page-shell-plan.md | 2 +- .../actions/charter-practice.template.yaml | 3 +- .../actions/guild-actions.template.yaml | 10 ++-- plugins/gildi/README.md | 2 +- plugins/gildi/src/actions/ActionsPanel.tsx | 52 +++++++++++-------- plugins/gildi/src/actions/useActions.ts | 2 +- plugins/gildi/src/chronicle/ChronicleRail.tsx | 44 ++++++++++------ plugins/gildi/src/chronicle/useSagas.ts | 5 +- plugins/gildi/src/drives/DrivesBand.test.tsx | 2 +- plugins/gildi/src/drives/DrivesBand.tsx | 22 +++++--- plugins/gildi/src/drives/useDrives.ts | 7 ++- plugins/gildi/src/guilds/useGuilds.ts | 6 ++- 13 files changed, 101 insertions(+), 63 deletions(-) diff --git a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md index 13b4ad5..2b3aedc 100644 --- a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md +++ b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md @@ -13,7 +13,7 @@ - Node **22 || 24**; Yarn **4.13** via Corepack. Build/test through `ws`: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi `, `ws test leidangr` (= `make ci`). Commit via `ws commit leidangr `; one shell command per call; no raw `git commit`/`push`. - **New frontend system only.** Keep the plugin's public surface stable (`gildiPlugin` default export, `rootRouteRef`, `GuildHallPage`). - **Presentation is a first-class goal** — curated cards, never a raw metadata dump. Clean display names + a brief description. -- **Crests are deterministic** from the group id/name (same input → same arms), honour the **rule of tincture** (a colour field pairs with a metal charge, or vice versa), render as **inline SVG** (no assets, no network), and fall back to a **monogram** if generation is disabled. Guilds only in v1. +- **Crests are deterministic** from the group id/name (same input → same arms), honour the **rule of tincture** (a colour field pairs with a metal charge, or vice versa), render as **inline SVG** (no assets, no network). A **monogram** fallback for a disabled/empty seed is DEFERRED — as shipped, an empty seed renders nothing (`null`). Guilds only in v1. - **Guild data model** (from the seed): guilds are `Group` `spec.type: guild` with a `siliconsaga.org/stewards` annotation (e.g. `aspect:security`); practices are `Component` `spec.type: practice` with `spec.owner` = the guild; the practice's aspect is on its `siliconsaga.org/aspect` annotation. - Design source of truth: `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` §4 (card system), §5 (crests), §7 (data model). - Verify catalog-react / hook imports (`useApi`, `catalogApiRef`, `useAsync`) against the installed package versions — the `tsc`/build gate is the safety net, as in Plan 1. @@ -153,8 +153,9 @@ export function tinctureHex(t: Tincture): string { - [ ] **Step 5: Run the test, expect PASS**: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test crest/blazon`. Fix until green (in particular the rule-of-tincture assertion: exactly one of field/charge is a metal). -- [ ] **Step 6: The Crest SVG component** (`plugins/gildi/src/crest/Crest.tsx`) — renders a heater shield clipped to its outline, a field (with optional division), and the charge; monogram fallback: +- [ ] **Step 6: The Crest SVG component** (`plugins/gildi/src/crest/Crest.tsx`) — renders a heater shield clipped to its outline, a field (with optional division), and the charge; an empty seed renders nothing (no monogram fallback — deferred): ```tsx +import { useId } from 'react'; import { blazonFor, tinctureHex, Charge } from './blazon'; const SHIELD = 'M9 7 L51 7 L51 33 Q51 52 30 63 Q9 52 9 33 Z'; @@ -170,9 +171,9 @@ function ChargeShape({ charge, fill }: { charge: Charge; fill: string }) { } export function Crest({ seed, size = 44, title }: { seed: string; size?: number; title?: string }) { + const id = useId(); if (!seed) return null; const b = blazonFor(seed); - const id = `gildi-crest-${seed.replace(/[^a-z0-9]/gi, '')}`; const f1 = tinctureHex(b.fieldTincture); const f2 = tinctureHex(b.fieldTincture2); const charge = tinctureHex(b.chargeTincture); diff --git a/docs/plans/2026-07-21-gildi-full-page-shell-plan.md b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md index b0a7347..d4186a9 100644 --- a/docs/plans/2026-07-21-gildi-full-page-shell-plan.md +++ b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md @@ -119,7 +119,7 @@ export function useSagas() { const res = await catalog.getEntities({ filter: { kind: 'Saga' } }); const views = res.items.map(s => { const touches = (s.spec?.touches as string[]) ?? []; - const guildRef = touches.find(t => t.startsWith('group:') && t.includes('gildi')) ?? touches.find(t => t.startsWith('group:')); + const guildRef = touches.find(t => t.startsWith('group:') && (t.split('/').pop() ?? '').endsWith('-gildi')) ?? touches.find(t => t.startsWith('group:')); const tf = (s.spec?.timeframe as { end?: string }) ?? {}; return { name: s.metadata.name, diff --git a/examples/mock-org/guildhall/actions/charter-practice.template.yaml b/examples/mock-org/guildhall/actions/charter-practice.template.yaml index f44b62d..addcf1c 100644 --- a/examples/mock-org/guildhall/actions/charter-practice.template.yaml +++ b/examples/mock-org/guildhall/actions/charter-practice.template.yaml @@ -11,7 +11,8 @@ spec: parameters: - title: New practice properties: - practice: { title: Practice name, type: string } + practice: { title: Practice name, type: string, minLength: 1 } + required: [practice] steps: - id: log name: Plan diff --git a/examples/mock-org/guildhall/actions/guild-actions.template.yaml b/examples/mock-org/guildhall/actions/guild-actions.template.yaml index f0e9266..415c402 100644 --- a/examples/mock-org/guildhall/actions/guild-actions.template.yaml +++ b/examples/mock-org/guildhall/actions/guild-actions.template.yaml @@ -14,8 +14,9 @@ spec: parameters: - title: New guild properties: - guild: { title: Guild name, type: string } - stewards: { title: 'Stewards (aspect: or craft:)', type: string } + guild: { title: Guild name, type: string, minLength: 1 } + stewards: { title: 'Stewards (aspect: or craft:)', type: string, minLength: 1 } + required: [guild, stewards] steps: - id: log name: Plan @@ -36,8 +37,9 @@ spec: parameters: - title: New drive properties: - drive: { title: Drive name, type: string } - guild: { title: Owning guild, type: string } + drive: { title: Drive name, type: string, minLength: 1 } + guild: { title: Owning guild, type: string, minLength: 1 } + required: [drive, guild] steps: - id: log name: Plan diff --git a/plugins/gildi/README.md b/plugins/gildi/README.md index 7701595..ccef8c4 100644 --- a/plugins/gildi/README.md +++ b/plugins/gildi/README.md @@ -4,7 +4,7 @@ The **Guild Hall** — a curated overview of the practice layer for this Backsta ## Status -Foundation shipped: a page at `/guild-hall` with a sidebar entry. Sections land incrementally (guilds first). See `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` in the leidangr repo for the full design. +A page at `/guild-hall` with a sidebar entry. Guilds, Drives, Chronicle, and Actions all render. See `docs/plans/2026-07-20-gildi-guildhall-hub-design.md` in the leidangr repo for the full design. ## Install diff --git a/plugins/gildi/src/actions/ActionsPanel.tsx b/plugins/gildi/src/actions/ActionsPanel.tsx index f7ffc29..9dff908 100644 --- a/plugins/gildi/src/actions/ActionsPanel.tsx +++ b/plugins/gildi/src/actions/ActionsPanel.tsx @@ -1,34 +1,40 @@ -import { Button, Typography } from '@material-ui/core'; +import type { ReactNode } from 'react'; +import { Typography } from '@material-ui/core'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; -import { Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { LinkButton, Progress, ResponseErrorPanel } from '@backstage/core-components'; import { useActions } from './useActions'; export function ActionsPanel() { const { actions, loading, error } = useActions(); - if (loading) return ; - if (error) return ; + let body: ReactNode; + if (loading) { + body = ; + } else if (error) { + body = ; + } else if (actions.length === 0) { + body = No actions yet.; + } else { + body = ( +
+ {actions.map(a => ( + } + title={a.description} + > + {a.title} + + ))} +
+ ); + } return (
Actions - {actions.length === 0 ? ( - No actions yet. - ) : ( -
- {actions.map(a => ( - - ))} -
- )} + {body}
); } diff --git a/plugins/gildi/src/actions/useActions.ts b/plugins/gildi/src/actions/useActions.ts index 3ac85dc..892c8e4 100644 --- a/plugins/gildi/src/actions/useActions.ts +++ b/plugins/gildi/src/actions/useActions.ts @@ -24,7 +24,7 @@ export function useActions() { name: t.metadata.name, title: t.metadata.title ?? t.metadata.name, description: t.metadata.description, - createHref: `/create/templates/default/${t.metadata.name}`, + createHref: `/create/templates/${t.metadata.namespace ?? 'default'}/${t.metadata.name}`, } as ActionView)); }, [catalog]); return { actions: state.value ?? [], loading: state.loading, error: state.error }; diff --git a/plugins/gildi/src/chronicle/ChronicleRail.tsx b/plugins/gildi/src/chronicle/ChronicleRail.tsx index 38f47ac..5f19cb9 100644 --- a/plugins/gildi/src/chronicle/ChronicleRail.tsx +++ b/plugins/gildi/src/chronicle/ChronicleRail.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import { Divider, Typography } from '@material-ui/core'; import { Progress, ResponseErrorPanel } from '@backstage/core-components'; import { useSagas } from './useSagas'; @@ -5,26 +6,37 @@ import { SagaCard } from './SagaCard'; export function ChronicleRail({ max = 4 }: { max?: number }) { const { sagas, loading, error } = useSagas(); - if (loading) return ; - if (error) return ; - if (sagas.length === 0) return

No sagas yet.

; const shown = sagas.slice(0, max); + let body: ReactNode; + if (loading) { + body = ; + } else if (error) { + body = ; + } else if (sagas.length === 0) { + body =

No sagas yet.

; + } else { + body = ( + <> +
+ {shown.map((s, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ {sagas.length > shown.length && ( + + +{sagas.length - shown.length} more in the chronicle + + )} + + ); + } return (

Recent chronicle

-
- {shown.map((s, i) => ( -
- {i > 0 && } - -
- ))} -
- {sagas.length > shown.length && ( - - +{sagas.length - shown.length} more in the chronicle - - )} + {body}
); } diff --git a/plugins/gildi/src/chronicle/useSagas.ts b/plugins/gildi/src/chronicle/useSagas.ts index 286a443..22043bc 100644 --- a/plugins/gildi/src/chronicle/useSagas.ts +++ b/plugins/gildi/src/chronicle/useSagas.ts @@ -1,6 +1,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export interface SagaView { name: string; title: string; description?: string; @@ -16,13 +17,13 @@ export function useSagas() { const res = await catalog.getEntities({ filter: { kind: 'Saga' } }); const views = res.items.map(s => { const touches = (s.spec?.touches as string[]) ?? []; - const guildRef = touches.find(t => t.startsWith('group:') && t.includes('gildi')) ?? touches.find(t => t.startsWith('group:')); + const guildRef = touches.find(t => t.startsWith('group:') && (t.split('/').pop() ?? '').endsWith('-gildi')) ?? touches.find(t => t.startsWith('group:')); const tf = (s.spec?.timeframe as { end?: string }) ?? {}; return { name: s.metadata.name, title: s.metadata.title ?? s.metadata.name, description: s.metadata.description, - entityRef: `saga:default/${s.metadata.name}`, + entityRef: stringifyEntityRef(s), skaldRef: s.spec?.skald as string | undefined, guildName: guildRef ? guildRef.split('/').pop() : undefined, end: tf.end, diff --git a/plugins/gildi/src/drives/DrivesBand.test.tsx b/plugins/gildi/src/drives/DrivesBand.test.tsx index e17ce4e..b6c2046 100644 --- a/plugins/gildi/src/drives/DrivesBand.test.tsx +++ b/plugins/gildi/src/drives/DrivesBand.test.tsx @@ -18,7 +18,7 @@ const catalogApi = { spec: { type: 'drive', owner: 'group:default/security-gildi', - timeframe: { start: '2026-05-01', end: '2026-07-31' }, + timeframe: { start: '2026-05-01', end: '2099-12-31' }, }, }], }; diff --git a/plugins/gildi/src/drives/DrivesBand.tsx b/plugins/gildi/src/drives/DrivesBand.tsx index 73370c6..9c50f0c 100644 --- a/plugins/gildi/src/drives/DrivesBand.tsx +++ b/plugins/gildi/src/drives/DrivesBand.tsx @@ -1,18 +1,28 @@ +import type { ReactNode } from 'react'; import { Progress, ResponseErrorPanel } from '@backstage/core-components'; import { useDrives } from './useDrives'; import { DriveCard } from './DriveCard'; export function DrivesBand() { const { drives, loading, error } = useDrives(); - if (loading) return ; - if (error) return ; - if (drives.length === 0) return

No active drives.

; - return ( -
-

Active & upcoming drives

+ let body: ReactNode; + if (loading) { + body = ; + } else if (error) { + body = ; + } else if (drives.length === 0) { + body =

No active drives.

; + } else { + body = (
{drives.map(d => ())}
+ ); + } + return ( +
+

Active & upcoming drives

+ {body}
); } diff --git a/plugins/gildi/src/drives/useDrives.ts b/plugins/gildi/src/drives/useDrives.ts index e915566..c9c6f88 100644 --- a/plugins/gildi/src/drives/useDrives.ts +++ b/plugins/gildi/src/drives/useDrives.ts @@ -1,6 +1,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export interface DriveView { name: string; title: string; description?: string; @@ -13,7 +14,7 @@ export function useDrives() { const catalog = useApi(catalogApiRef); const state = useAsync(async () => { const res = await catalog.getEntities({ filter: { kind: 'Cycle', 'spec.type': 'drive' } }); - return res.items.map(c => { + const views = res.items.map(c => { const owner = (c.spec?.owner as string) ?? ''; const ownerName = owner.startsWith('group:') ? owner.split('/').pop() : undefined; const tf = (c.spec?.timeframe as { start?: string; end?: string }) ?? {}; @@ -21,11 +22,13 @@ export function useDrives() { name: c.metadata.name, title: c.metadata.title ?? c.metadata.name, description: c.metadata.description, - entityRef: `cycle:default/${c.metadata.name}`, + entityRef: stringifyEntityRef(c), ownerGuildName: ownerName, start: tf.start, end: tf.end, } as DriveView; }); + const today = new Date().toISOString().slice(0, 10); + return views.filter(d => !d.end || d.end >= today); }, [catalog]); return { drives: state.value ?? [], loading: state.loading, error: state.error }; } diff --git a/plugins/gildi/src/guilds/useGuilds.ts b/plugins/gildi/src/guilds/useGuilds.ts index bc7c91f..aa94a56 100644 --- a/plugins/gildi/src/guilds/useGuilds.ts +++ b/plugins/gildi/src/guilds/useGuilds.ts @@ -1,6 +1,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import type { Entity } from '@backstage/catalog-model'; export interface GuildView { @@ -25,11 +26,12 @@ export function useGuilds() { const practicesByOwner = new Map(); for (const p of practicesRes.items) { const owner = (p.spec?.owner as string) ?? ''; - const key = owner.includes('/') ? owner : `group:default/${owner.replace(/^group:/, '')}`; + if (!owner) continue; + const key = stringifyEntityRef(parseEntityRef(owner, { defaultKind: 'Group', defaultNamespace: 'default' })); practicesByOwner.set(key, [...(practicesByOwner.get(key) ?? []), p]); } const guilds: GuildView[] = guildsRes.items.map(g => { - const ref = `group:default/${g.metadata.name}`; + const ref = stringifyEntityRef(g); const stewards = (g.metadata.annotations?.[STEWARDS] ?? '') .split(',').map(s => s.trim()).filter(Boolean) .filter(s => s.startsWith('aspect:')).map(s => s.slice('aspect:'.length)); From 1e73c1a4f5653e0a9e39f596bb87fb983e0a1578 Mon Sep 17 00:00:00 2001 From: Cervator Date: Fri, 24 Jul 2026 00:07:53 -0400 Subject: [PATCH 15/16] =?UTF-8?q?fix(gildi):=20PR=20#12=20review=20round?= =?UTF-8?q?=202=20=E2=80=94=20owner-parse=20guard,=20local-date=20drive=20?= =?UTF-8?q?filter,=20entity-ref=20keys,=20namespace=20action=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit re-scan (all valid): - useGuilds: guard parseEntityRef per practice (try/catch, skip malformed owner) so one bad entity can't reject the whole section. - useDrives: build the expiry `today` from local Date fields, not toISOString() (UTC), so a drive ending today doesn't vanish during the prior local evening. - DrivesBand/ChronicleRail: list keys use `entityRef` (namespace-unique), not bare `name`. - ActionsPanel.test: add a non-default-namespace Template case asserting the Create link preserves the namespace (completes the earlier action-links fix so CodeRabbit's thread resolves). - Plan docs: MD031 blank line before a fence; reconcile the useDrives/useSagas samples with the shipped stringifyEntityRef + active/upcoming filter. Verified: ws test (25) + ws lint green. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-21-gildi-crest-and-guilds-plan.md | 1 + .../2026-07-21-gildi-full-page-shell-plan.md | 12 +++++++++--- plugins/gildi/src/actions/ActionsPanel.test.tsx | 15 +++++++++++++++ plugins/gildi/src/chronicle/ChronicleRail.tsx | 2 +- plugins/gildi/src/drives/DrivesBand.tsx | 2 +- plugins/gildi/src/drives/useDrives.ts | 3 ++- plugins/gildi/src/guilds/useGuilds.ts | 7 ++++++- 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md index 2b3aedc..671fb61 100644 --- a/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md +++ b/docs/plans/2026-07-21-gildi-crest-and-guilds-plan.md @@ -154,6 +154,7 @@ export function tinctureHex(t: Tincture): string { - [ ] **Step 5: Run the test, expect PASS**: `ws exec leidangr corepack yarn workspace @siliconsaga/plugin-gildi test crest/blazon`. Fix until green (in particular the rule-of-tincture assertion: exactly one of field/charge is a metal). - [ ] **Step 6: The Crest SVG component** (`plugins/gildi/src/crest/Crest.tsx`) — renders a heater shield clipped to its outline, a field (with optional division), and the charge; an empty seed renders nothing (no monogram fallback — deferred): + ```tsx import { useId } from 'react'; import { blazonFor, tinctureHex, Charge } from './blazon'; diff --git a/docs/plans/2026-07-21-gildi-full-page-shell-plan.md b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md index d4186a9..08906ab 100644 --- a/docs/plans/2026-07-21-gildi-full-page-shell-plan.md +++ b/docs/plans/2026-07-21-gildi-full-page-shell-plan.md @@ -30,6 +30,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export interface DriveView { name: string; title: string; description?: string; @@ -42,7 +43,7 @@ export function useDrives() { const catalog = useApi(catalogApiRef); const state = useAsync(async () => { const res = await catalog.getEntities({ filter: { kind: 'Cycle', 'spec.type': 'drive' } }); - return res.items.map(c => { + const views = res.items.map(c => { const owner = (c.spec?.owner as string) ?? ''; const ownerName = owner.startsWith('group:') ? owner.split('/').pop() : undefined; const tf = (c.spec?.timeframe as { start?: string; end?: string }) ?? {}; @@ -50,11 +51,15 @@ export function useDrives() { name: c.metadata.name, title: c.metadata.title ?? c.metadata.name, description: c.metadata.description, - entityRef: `cycle:default/${c.metadata.name}`, + entityRef: stringifyEntityRef(c), ownerGuildName: ownerName, start: tf.start, end: tf.end, } as DriveView; }); + // active & upcoming only — drop drives whose end date has passed (local date) + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return views.filter(d => !d.end || d.end >= today); }, [catalog]); return { drives: state.value ?? [], loading: state.loading, error: state.error }; } @@ -104,6 +109,7 @@ export function DriveCard({ drive }: { drive: DriveView }) { import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export interface SagaView { name: string; title: string; description?: string; @@ -125,7 +131,7 @@ export function useSagas() { name: s.metadata.name, title: s.metadata.title ?? s.metadata.name, description: s.metadata.description, - entityRef: `saga:default/${s.metadata.name}`, + entityRef: stringifyEntityRef(s), skaldRef: s.spec?.skald as string | undefined, guildName: guildRef ? guildRef.split('/').pop() : undefined, end: tf.end, diff --git a/plugins/gildi/src/actions/ActionsPanel.test.tsx b/plugins/gildi/src/actions/ActionsPanel.test.tsx index fcae1a4..6cff179 100644 --- a/plugins/gildi/src/actions/ActionsPanel.test.tsx +++ b/plugins/gildi/src/actions/ActionsPanel.test.tsx @@ -19,6 +19,17 @@ const catalogApi = { }, spec: { type: 'guildhall-action', owner: 'group:default/team-devex' }, }, + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'establish-a-guild', + namespace: 'custom', + title: 'Establish a guild', + tags: ['guild-hall'], + }, + spec: { type: 'guildhall-action', owner: 'group:default/team-devex' }, + }, ], }; } @@ -36,5 +47,9 @@ describe('ActionsPanel', () => { const link = (await screen.findByText('Charter a practice')).closest('a'); expect(link).toHaveAttribute('href', '/create/templates/default/charter-a-practice'); + + // a non-default namespace is preserved in the Create link + const nsLink = (await screen.findByText('Establish a guild')).closest('a'); + expect(nsLink).toHaveAttribute('href', '/create/templates/custom/establish-a-guild'); }); }); diff --git a/plugins/gildi/src/chronicle/ChronicleRail.tsx b/plugins/gildi/src/chronicle/ChronicleRail.tsx index 5f19cb9..8b53756 100644 --- a/plugins/gildi/src/chronicle/ChronicleRail.tsx +++ b/plugins/gildi/src/chronicle/ChronicleRail.tsx @@ -19,7 +19,7 @@ export function ChronicleRail({ max = 4 }: { max?: number }) { <>
{shown.map((s, i) => ( -
+
{i > 0 && }
diff --git a/plugins/gildi/src/drives/DrivesBand.tsx b/plugins/gildi/src/drives/DrivesBand.tsx index 9c50f0c..384b3ae 100644 --- a/plugins/gildi/src/drives/DrivesBand.tsx +++ b/plugins/gildi/src/drives/DrivesBand.tsx @@ -15,7 +15,7 @@ export function DrivesBand() { } else { body = (
- {drives.map(d => ())} + {drives.map(d => ())}
); } diff --git a/plugins/gildi/src/drives/useDrives.ts b/plugins/gildi/src/drives/useDrives.ts index c9c6f88..67ccb9e 100644 --- a/plugins/gildi/src/drives/useDrives.ts +++ b/plugins/gildi/src/drives/useDrives.ts @@ -27,7 +27,8 @@ export function useDrives() { start: tf.start, end: tf.end, } as DriveView; }); - const today = new Date().toISOString().slice(0, 10); + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; return views.filter(d => !d.end || d.end >= today); }, [catalog]); return { drives: state.value ?? [], loading: state.loading, error: state.error }; diff --git a/plugins/gildi/src/guilds/useGuilds.ts b/plugins/gildi/src/guilds/useGuilds.ts index aa94a56..2321ade 100644 --- a/plugins/gildi/src/guilds/useGuilds.ts +++ b/plugins/gildi/src/guilds/useGuilds.ts @@ -27,7 +27,12 @@ export function useGuilds() { for (const p of practicesRes.items) { const owner = (p.spec?.owner as string) ?? ''; if (!owner) continue; - const key = stringifyEntityRef(parseEntityRef(owner, { defaultKind: 'Group', defaultNamespace: 'default' })); + let key: string; + try { + key = stringifyEntityRef(parseEntityRef(owner, { defaultKind: 'Group', defaultNamespace: 'default' })); + } catch { + continue; // skip a practice with a malformed owner ref rather than failing the whole section + } practicesByOwner.set(key, [...(practicesByOwner.get(key) ?? []), p]); } const guilds: GuildView[] = guildsRes.items.map(g => { From 6315a2453d915f5ad3237aa10154bf924277713d Mon Sep 17 00:00:00 2001 From: Cervator Date: Fri, 24 Jul 2026 13:24:44 -0400 Subject: [PATCH 16/16] fix(gildi): derive drive/saga crest seeds via parseEntityRef (Copilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot flagged both crest-seed extractions using naive `owner.split('/').pop()`, which mis-derives the seed for valid non-canonical refs (e.g. `group:security-gildi` with no namespace yields the whole string as the seed → wrong/blank crest). The current seed is all-canonical so there is no bug today, but this is the same fragility already fixed with parseEntityRef in useGuilds — so make it consistent: - useDrives: parse `spec.owner` with the backend's { defaultKind: Group, defaultNamespace: default } and take the name only when kind is Group; malformed refs leave the crest off rather than seeding it garbage. - useSagas: resolve each `touches` entry the same way, keep the group-kind names, then prefer the `-gildi` guild (unchanged selection semantics). Verified: ws test (25) + ws lint green. Co-Authored-By: Claude Opus 4.8 --- plugins/gildi/src/chronicle/useSagas.ts | 18 +++++++++++++++--- plugins/gildi/src/drives/useDrives.ts | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/plugins/gildi/src/chronicle/useSagas.ts b/plugins/gildi/src/chronicle/useSagas.ts index 22043bc..4f9e190 100644 --- a/plugins/gildi/src/chronicle/useSagas.ts +++ b/plugins/gildi/src/chronicle/useSagas.ts @@ -1,7 +1,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { stringifyEntityRef } from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; export interface SagaView { name: string; title: string; description?: string; @@ -17,7 +17,19 @@ export function useSagas() { const res = await catalog.getEntities({ filter: { kind: 'Saga' } }); const views = res.items.map(s => { const touches = (s.spec?.touches as string[]) ?? []; - const guildRef = touches.find(t => t.startsWith('group:') && (t.split('/').pop() ?? '').endsWith('-gildi')) ?? touches.find(t => t.startsWith('group:')); + // resolve each touch to a guild name via the backend's ref defaults, so + // group:name / Group:default/name / bare name all seed the crest correctly + const guildNames = touches + .map(t => { + try { + const ref = parseEntityRef(t, { defaultKind: 'Group', defaultNamespace: 'default' }); + return ref.kind.toLowerCase() === 'group' ? ref.name : undefined; + } catch { + return undefined; + } + }) + .filter((n): n is string => !!n); + const guildName = guildNames.find(n => n.endsWith('-gildi')) ?? guildNames[0]; const tf = (s.spec?.timeframe as { end?: string }) ?? {}; return { name: s.metadata.name, @@ -25,7 +37,7 @@ export function useSagas() { description: s.metadata.description, entityRef: stringifyEntityRef(s), skaldRef: s.spec?.skald as string | undefined, - guildName: guildRef ? guildRef.split('/').pop() : undefined, + guildName, end: tf.end, } as SagaView; }); diff --git a/plugins/gildi/src/drives/useDrives.ts b/plugins/gildi/src/drives/useDrives.ts index 67ccb9e..c3a9334 100644 --- a/plugins/gildi/src/drives/useDrives.ts +++ b/plugins/gildi/src/drives/useDrives.ts @@ -1,7 +1,7 @@ import useAsync from 'react-use/lib/useAsync'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { stringifyEntityRef } from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; export interface DriveView { name: string; title: string; description?: string; @@ -16,7 +16,17 @@ export function useDrives() { const res = await catalog.getEntities({ filter: { kind: 'Cycle', 'spec.type': 'drive' } }); const views = res.items.map(c => { const owner = (c.spec?.owner as string) ?? ''; - const ownerName = owner.startsWith('group:') ? owner.split('/').pop() : undefined; + let ownerName: string | undefined; + if (owner) { + try { + // parse with the backend's defaults so bare / capitalized / namespaced + // refs (name, Group:default/name, group:name) all yield the guild's name + const ref = parseEntityRef(owner, { defaultKind: 'Group', defaultNamespace: 'default' }); + if (ref.kind.toLowerCase() === 'group') ownerName = ref.name; + } catch { + // leave undefined for a malformed owner ref — no crest rather than a bad one + } + } const tf = (c.spec?.timeframe as { start?: string; end?: string }) ?? {}; return { name: c.metadata.name,