diff --git a/backlog/tasks/task-561 - Give-elements-an-icon-and-a-colour-from-icon-packs-Groma.md-publishes.md b/backlog/tasks/task-561 - Give-elements-an-icon-and-a-colour-from-icon-packs-Groma.md-publishes.md index 45dfc257..1af7d1a5 100644 --- a/backlog/tasks/task-561 - Give-elements-an-icon-and-a-colour-from-icon-packs-Groma.md-publishes.md +++ b/backlog/tasks/task-561 - Give-elements-an-icon-and-a-colour-from-icon-packs-Groma.md-publishes.md @@ -1,10 +1,11 @@ --- id: TASK-561 title: 'Give elements an icon and a colour, from icon packs Groma.md publishes' -status: To Do -assignee: [] +status: In Progress +assignee: + - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-08 15:22' +updated_date: '2026-10-11 19:48' labels: - senior dependencies: [] @@ -23,6 +24,111 @@ references: - screen - docs/component-markdown.md - docs/scanners/publishing.md + - src/viewers/web/iso/painting/style.ts + - src/viewers/web/iso/painting/text.ts + - src/icon-packs.ts + - src/viewers/tui/panes/details.ts + - src/viewers/web/organisms/details.ts + - src/edit.ts + - src/scanner/modules/package.ts + - src/scanner/modules/config.ts + - src/scanner/modules/settings.ts + - icon-packs +modified_files: + - src/types.ts + - src/architecture-model.ts + - src/element-appearance.ts + - src/markdown-emitter.ts + - src/authoring-conflict.ts + - src/write-commands.ts + - src/core.ts + - src/viewers/web/organisms/writes.ts + - src/viewers/web/organisms/details.ts + - docs/component-markdown.md + - src/edit.ts + - src/plain-world.ts + - src/scanner/modules/config.ts + - src/scanner/modules/package.ts + - src/icon-packs.ts + - src/plugin-management.ts + - src/scanner/modules/published.ts + - src/sheet/types.ts + - src/sheet/place.ts + - src/viewers/web/iso/painting/appearance.ts + - src/viewers/web/iso/painting/text.ts + - src/viewers/web/iso/painting/buildings.ts + - src/viewers/web/iso/painting/ground.ts + - src/viewers/web/chrome/colour-legend.ts + - src/viewers/web/render.ts + - src/viewers/tui/panes/details.ts + - src/lint-command.ts + - src/scanner/modules/settings-model.ts + - src/scanner/modules/settings.ts + - plugins/icons/architecture/icons/database.svg + - plugins/icons/architecture/icons/queue.svg + - plugins/icons/architecture/icons/cache.svg + - plugins/icons/architecture/icons/browser.svg + - plugins/icons/architecture/icons/mobile-app.svg + - plugins/icons/architecture/icons/service.svg + - plugins/icons/architecture/icons/scheduler.svg + - plugins/icons/architecture/icons/file-store.svg + - plugins/icons/architecture/icons/mail.svg + - plugins/icons/architecture/icons/identity.svg + - plugins/icons/architecture/icons/gateway.svg + - plugins/icons/architecture/icons/search.svg + - plugins/icons/architecture/package.json + - plugins/icons/architecture/build.ts + - package.json + - scripts/scanner-release.ts + - docs/icons.md + - docs/scanners/publishing.md + - src/viewers/web/scanners/settings.ts + - groma/systems/groma-md/containers/cli/components/src-architecture-model.md + - groma/systems/groma-md/containers/export/components/map.md + - groma/systems/groma-md/containers/export/components/shell.md + - groma/systems/groma-md/containers/cli/components/icon-packs.md + - bun.lock + - src/viewers/web/iso/painting/style.ts + - test-bun/scanner-update.test.ts + - test-bun/tui-source.test.ts + - test-bun/iso-map.test.ts + - src/viewers/web/sharing/cover.ts + - test-bun/web-stats.test.ts + - src/viewers/web/page.ts + - src/curate.ts + - test-bun/system-curation.test.ts + - src/history/comparison.ts + - src/viewers/web/comparison/details.ts + - test-bun/revision-comparison.test.ts + - docs/agent-instructions/structure.md + - test-bun/web-sharing.test.ts + - docs/scanners/setup.md + - src/scanner/modules/official-catalog.ts + - src/viewers/web/iso/painting/scale.ts + - docs/agent-instructions/describe.md + - docs/agent-instructions/inspect.md + - docs/architecture-findings.md + - groma/systems/groma-md/containers/cli/components/modules-settings.md + - groma/systems/groma-md/containers/cli/components/package.md + - groma/systems/groma-md/containers/cli/components/src-lint-command.md + - scripts/reproduce-scanner-boundaries.ts + - src/instructions.ts + - src/plugin-cli.ts + - src/sheet/measure.ts + - src/viewers/web/iso/projection/project.ts + - test-bun/compiled-scanner.test.ts + - test-bun/icon-packs.test.ts + - test-bun/lint-command.test.ts + - test-bun/scanner-compatibility.test.ts + - test-bun/scanner-exclusions.test.ts + - test-bun/scanner-installation.test.ts + - test-bun/scanner-release.test.ts + - test-bun/scanner-restore.test.ts + - test-bun/scanner-settings-lifecycle.test.ts + - test-bun/source-coverage.test.ts + - test-bun/viewer-scanner-availability.test.ts + - test-bun/web-startup.test.ts + - test-bun/work-source-selection.test.ts type: feature ordinal: 1 --- @@ -39,18 +145,102 @@ TASK-568 replaces scanners.json with one plugin file, `/plugins.json ## Acceptance Criteria -- [ ] #1 An element can carry an icon name and an accent colour in its groma fields, set with groma edit --icon --colour and in the web details form, validated like the other fields, documented in docs/component-markdown.md, and preserved by scans. -- [ ] #2 An icon pack is a package whose package.json declares under groma.icons the SVG icons it ships. groma plugin add installs a pack: it resolves npm, a local folder or git the way scanners are resolved and records the pack in the icons section of /plugins.json. groma plugin list and groma plugin remove manage it, and the web settings dialog shows the installed packs. -- [ ] #3 An icon name resolves through the installed packs or is an emoji. An unknown name leaves the building without an icon and appears in groma lint as a finding, never as a failed map. -- [ ] #4 The web map draws the icon and the accent colour on the building and its label the same way in light, dark and blueprint. The web details pane and the terminal details pane show them. The cover export includes the icon and the colour. -- [ ] #5 A legend on the map lists the accent colours in use with a count each, and hides when no element has one. +- [x] #1 An element can carry an icon name and an accent colour in its groma fields, set with groma edit --icon --colour and in the web details form, validated like the other fields, documented in docs/component-markdown.md, and preserved by scans. +- [x] #2 An icon pack is a package whose package.json declares under groma.icons the SVG icons it ships. groma plugin add installs a pack: it resolves npm, a local folder or git the way scanners are resolved and records the pack in the icons section of /plugins.json. groma plugin list and groma plugin remove manage it, and the web settings dialog shows the installed packs. +- [x] #3 An icon name resolves through the installed packs or is an emoji. An unknown name leaves the building without an icon and appears in groma lint as a finding, never as a failed map. +- [x] #4 The web map draws the icon and the accent colour on the building and its label the same way in light, dark and blueprint. The web details pane and the terminal details pane show them. The cover export includes the icon and the colour. +- [x] #5 A legend on the map lists the accent colours in use with a count each, and hides when no element has one. - [ ] #6 One pack lives under plugins/icons/ with at least twelve generic architecture icons (database, queue, cache, browser, mobile app, service, scheduler, file store, mail, identity, gateway, search), built and published the same way as the scanner packages, and docs/ describes how to make and publish a pack. ## Definition of Done - [ ] #1 Acceptance criteria have objective verification evidence. -- [ ] #2 Relevant checks pass and changes remain task-scoped. -- [ ] #3 Public contracts or documentation are updated when behavior changes. -- [ ] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. +- [x] #2 Relevant checks pass and changes remain task-scoped. +- [x] #3 Public contracts or documentation are updated when behavior changes. +- [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. + +## Implementation Plan + + +1. Where the concept belongs. An icon and a colour are Groma presentation fields on an existing OKF concept (groma.icon, groma.colour). They add no C4 element, level or relationship. An ordinary Markdown or OKF reader sees two readable frontmatter values beside the unchanged title, prose and links. Groma interprets them: src/architecture-model.ts validates the colour (a name from src/element-appearance.ts or #RRGGBB), groma edit --icon --colour and the web details form write both through the existing meaning writer (src/edit.ts, src/markdown-emitter.ts), scans preserve them, a combine refuses a source that has either (src/curate.ts), and a revision comparison treats both as the component's own content (src/history/comparison.ts). + +2. Icon packs are the third plugin kind. plugins.json has an icons section beside scanners and workSources (ConfiguredIconPack in src/scanner/modules/config.ts): a required array, written in a fixed key order, with ids unique across all sections through requireUniqueIds. A pack is a package whose package.json declares groma.icons with an id and SVG files. src/scanner/modules/package.ts resolves it to ResolvedIconPackage, which has the SVG file of each icon and no entry, because a pack holds no code; a declared icon whose file is missing or is not an .svg file blocks the pack there. src/plugin-management.ts adds, removes, updates and restores a pack through the same functions as every other kind; nothing in src/icon-packs.ts writes plugins.json. The settings row is IconPackSetting and official packs are named by officialIconPackIds from the embedded manifest. + +3. Resolving an icon (src/icon-packs.ts). iconPackInventory lists the configured packs in order with what this computer has of each; a pack that cannot be used is blocked with the reason and never fails the caller. attachElementIcons, called by loadAnnotatedArchitecture, sets resolvedIcon on each element: a pack image as an SVG data URI (the first configured pack answers a bare name, pack:name picks one), the emoji itself, or nothing for an unknown name. unknownIcons gives groma lint one line per unknown name. Loading never touches the network. + +4. Drawing. The sheet reserves the room: one roof line above a building's title and ICON_ADVANCE font sizes before a surface name (src/sheet/measure.ts); Building.lines stays the title alone, so the terminal map and the grow animation are unaffected. The web map draws a pack icon as an image and an emoji as SVG text (iso/painting/appearance.ts, text.ts). An accent travels as the color attribute of the element's group (withAccent), because the cover renderer reads color and not var(). accentRules in iso/painting/style.ts paints the outline in the accent, the element's own surfaces as the accent mixed 22 percent into the theme's paper and its title as the accent mixed 45 percent toward the theme's ink; at that share every named accent's title has a contrast of at least 4.5 on its own surface in light, dark and blueprint. The rules sit between the rest colours and the draft rule, so selection, flow, comparison and draft states win. Covers are rendered by resvg, which reads none of :not(), :is(), var() and color-mix(): they get the same rules with explicit colours for each accent in the scene, and they hide emoji because the bundled fonts have none. The web details pane draws the pack icon and the accent in the title and names both in its meta line, the terminal details pane shows two text rows, and the legend under the hierarchy counts elements per resolved colour (chrome/colour-legend.ts). + +5. The official pack. plugins/icons/architecture holds twelve SVG icons and its manifest. scripts/scanner-release.ts builds and publishes it apart from the scanner builders, under a named staging directory. + +6. Tests, each with its rule, the wrong result it catches and the gap it closes. +- test-bun/icon-packs.test.ts, resolving: an icon is the first configured pack that has its name, the pack it names, or the emoji itself, and an unknown name draws nothing (AC3). Catches an emoji left undrawn, an unknown name drawn, or a later pack answering a bare name. No other test resolves an icon. +- test-bun/icon-packs.test.ts, updating: an update keeps a pack's place in the order, which is its priority for bare names (docs/icons.md, a defect reproduced in review). Catches the updated pack moving behind the others. The scanner update test cannot show it because scanners are sorted by id. +- test-bun/work-source-selection.test.ts, ids: one id names one plugin across the sections of plugins.json (TASK-568 contract, extended to icons). Catches an icon pack that reuses a scanner's id being read or written. The test covered scanners and work sources only. +- test-bun/web-stats.test.ts: the legend lists each colour in use once with its count (AC5). Catches a name and its hex value as separate rows. The legend count had no coverage. +- test-bun/system-curation.test.ts: a combine never drops authored fields of an absorbed record. Catches the document of a source with an icon or a colour being deleted. No combine test covered authored metadata. +- test-bun/revision-comparison.test.ts: a change to a component's own content marks it modified and is named as a reason, checked for icon and for colour separately. Catches either field missing from the fingerprint or from the reasons. Existing tests change title, parent and prose only. +- test-bun/web-sharing.test.ts, cover: a cover paints every surface in its rest colour and an accented one in its accent mixed into that theme's paper (AC4 and the stylesheet's rule that covers read plain selectors and explicit colours). Catches surfaces painted black and a white-mixed surface in the dark cover. The sharing tests checked only the PNG header. +- test-bun/web-sharing.test.ts, contrast: the title of every named accent reads on its own surface at 4.5 or more in every theme (owner decision). Catches a new accent, share or palette that makes a title unreadable. Nothing else measures it. +- test-bun/iso-map.test.ts: a surface label stays inside its band and camera, with and without an icon (AC4, a reproduced overlap). Catches an icon outside the selectable title, and labels without an icon losing their checks. +- test-bun/tui-source.test.ts: the terminal details pane shows the complete icon and colour at its fixed width and keeps the action cursor on its row (AC4, a reproduced truncation). + +7. Verification. bun run typecheck, bunx biome lint with no new warning, the focused bun tests with the repository's flags, the node tests, tui-test on a scratch project for a terminal card with an icon and for the Plugins screen, headless Chromium on an exported scratch project in light, dark and blueprint for surfaces, titles, selection, draft, flow and comparison, and the three covers. The full bun run check is run by the orchestrator. No publication, tag or push. + + +## Implementation Notes + + +Implemented icon/colour metadata, CLI and web editing, pack resolution via TASK-568 plugin machinery, embedded SVG rendering in map/covers, details, lint, colour counts, and the twelve-icon official pack plus release/documentation wiring. Cold simplicity review found missing workspace lock metadata and plugin-id collision validation; both corrected. Single-pack restore was narrowed to the selected pack. Existing live Colour by rules explicitly override authored fill only while enabled, then restore authored colours. Verification is limited to static flow/diff review and authorized groma scan; no installs, builds or tests were run as instructed. + +Acceptance criteria implemented and checked by tracing the declared flow and reviewing source changes; runtime validation was explicitly excluded by the stage instruction. The authorized groma scan returned ok; groma view confirms icon-packs and icon-pack-release under the Groma application, with appearance drawing folded into map and colour counts into shell. git diff --check reports no whitespace errors. No tests were added or run. DoD check execution remains unverified. + +Implementer specification and quality reviews traced every changed entry point to storage and rendering, preserving the simultaneous criticality, history and plugin changes. Final full-context complexity review found no material remaining issue. New source files stay under 500 lines. The colour counts reuse the existing legend host. No status change or commit performed. Runtime checks and publication remain unexecuted per user instruction. + +Review fixes: focused 31 tests passed; changed-file TypeScript and Biome passed. Chromium verified fixed rest accents, selection/flow/comparison and hollow draft states in all three themes, separate sibling icon/title bands, and neutral relationship/flow headings. All three cover PNGs contain authored accent pixels. Full npm update reproduction kept primary before secondary. Native tui-test showed complete icon and colour at 120x36 and 200x60. Cold simplicity review passed with one accepted cleanup: remove duplicate title writes because paintElementAppearance owns its span content. Awaiting integration validation on TASK-568 local fixes. + +Integrated on TASK-568 fix 7a4455b2; the replayed icon commit is c5f41b2a. Preserved work-source release staging/publication, shared preparePluginUpdate, configuredScanners duplicate validation and configuration-based restore. These last two simplify the original reviewed code without duplicate helpers. No TASK-568-only source files were added to this fix delta. +Implementer specification review: all five reported defects and four local simplification requests are addressed within existing presentation/plugin owners. Full check passed: Node 16/16; Bun 782 passed, 51 skipped, zero failures. Changed and integration-overlap files pass Biome without complexity warnings; the full repository retains one unrelated baseline lint warning. Own quality review traced CLI/configuration -> pack catalog -> shared SVG -> browser/cover, and element selection -> web/TUI details -> action cursor. Existing names and boundaries make ownership clear. All changed files remain below 500 lines. No new architecture concepts or generic plugin layer. +Regression proof: temporary copies of the original modules fail the new icon-band tests in both projections, configured-order test and terminal visible-width test; fixed focused tests pass. The width test asserts separate complete appearance rows and a real outline cursor, so it observes the reported clipped-output defect. A second full check runs after the final assertion change. +Two local scans created zero records and left groma/ clean; missing optional C#/Go/Java/Rust worker binaries retained saved evidence. Actual npm publication is excluded by this fix request and remains unverified. + +Final exact-content check passed: Node 16/16; Bun 782 passed, 51 skipped, 0 failed (833 tests). Full-context complexity review passed with no material findings or additional abstraction/deletion needed. Final diff check passed. The icon package built all twelve declared SVG assets. All reported PR #118 fixes are verified; no authority-backed code blocker remains. TASK-561 remains In Progress with AC6 and DoD1 unchecked only because actual public icon-pack publication is excluded and has not been verified. No npm publication, tag or remote push was performed. + +Review remediation, findings A to F of the PR 118 review. +A. Removed the Twemoji download (emojiSvg) from src/icon-packs.ts; loading the architecture no longer touches the network. The loader now sets resolvedIcon on each element: a pack's SVG as a data URI, or the emoji itself; an unknown name stays unresolved. The map draws an emoji as SVG text centred in the icon's square (iconNode in iso/painting/appearance.ts); the web details meta line and the terminal already show it as text. Measured the cover renderer: resvg with only the bundled DejaVu fonts draws an empty box for an emoji, so covers hide emoji icons (one rule in sharing/cover.ts) and docs/icons.md says covers show pack icons only. +B. The colour legend is a static element of the page, painted from paintWorld, styled by colourLegendCss through page.ts. It counts by the resolved colour (colourCounts); accentColour returns lower-case hex and the gray alias is gone. +C. requireCombinableSources refuses a source with an authored icon or colour; the agent guide lists both. +D. ownContent includes icon and colour; the reasons list names them and the What tab shows the changed values, so the reason link leads to something. +E. iconPackInventory reports error.message; the details title uses a stylesheet (elementTitleCss) and a custom property instead of inline styles, and paintElementTitle is named for what it paints; imports follow each file's grouping; the release script builds the icon pack apart from scannerBuilders under a named iconPackDirectory. The icon pack manifest gained the repository field every other published package gets at staging: the release publishes with npm provenance, which checks it. Publication itself was not run. +F. docs/icons.md and the Element appearance section follow the code; architecture records icon-packs and map updated through groma edit. +Checks: bun run typecheck passed. bunx biome lint: 1 warning and 2 infos, the same three as before the change, none in a changed file. bun scripts/lint-web-scrollbars.ts passed. Focused bun tests passed: 113 tests in 19 files (web-stats, revision-comparison, system-curation, scanner-update, iso-map, inspect-details, web-page-inset, tui-source, detach, rename, editing, web-export, web-sharing, web-shell, chrome, plain-view, sheet-scene, lint-command, scanner-release). The legend, combine and comparison tests failed before their fixes. groma lint on a scratch project reported only the unknown icon name. Headless Chromium on an exported scratch project showed the legend rows, the details title with its pack icon and colour, and emoji drawn as text beside container, system and building titles in the light theme. The full bun run check was not run in this lane. + +Finding G (found while rendering covers for A, not in the review list). Reproduced: with the branch as it was before this pass, the cover of test/fixtures/viewer-view, which has no icon and no colour, had every island, slab and building filled black (28020 pure black pixels in each theme of a three-box cover). Cause: the earlier fix pass wrote the rest colours as :not(.accented) rules, and resvg drops a selector that uses :not() or :is() together with every selector listed after it. Fix: withAccent in iso/painting/appearance.ts puts the accent on the element's group as two inherited values (color for the accent, fill for its tint) and the class accented; two plain rules in mapDrawingCss, placed after the rest colours and before the draft rule, hand them to the element's own faces, ground and title. The rest rules are plain again, as they were before the icon work, and the recursive rewrite of child nodes is gone. Selection, flow, comparison and draft rules keep winning by order and specificity. Checked: the new cover test fails on the earlier branch state and passes now; covers of a scratch project in light, dark and blueprint show rest tints, accents, pack icons and no emoji boxes; headless Chromium in light, dark and blueprint shows rest colours, accented surfaces and titles, a selected and a context accented surface with the highlight stroke and title, and accented drafts hollow and dashed in their accent. Not exercised in a browser: flow, hover and comparison states over an accent; their rules are later in the stylesheet or more specific than the two accent rules. Checks after G: bun run typecheck passed; bunx biome lint unchanged (1 warning, 2 infos, none in a changed file); 105 focused bun tests in 17 files passed (web-sharing, iso-map, unidentified-container, web-map-pointer, web-export, web-stats, revision-comparison, system-curation, scanner-update, inspect-details, web-page-inset, tui-source, web-map-presentation, web-map-highlights, web-layer-mode, sheet-scene, web-camera-layer). + +After G: reworded the icon paragraph of docs/icons.md so it says only what the code does (a resolved pack icon travels with the map; the web pane also draws it beside the title). node --import=tsx --test test/*.test.ts passed, 16 of 16. + +Restack and finding X. Merged the plugins.json branch (remediate/117 at 7d1c74d8) into this branch (merge commit 51976c1c): its version was kept wherever this branch only had older TASK-568 code, and the icon-pack code was moved to its names (readPluginConfig, resolvePluginPackage, PluginInstallOptions and so on). package.json and bun.lock did not change in the merge, so no install was needed. Because the branch's unions give each kind its own fields, the merge already gave the icon pack its own member of the resolved-package union and of PluginSetting (IconPackSetting, no technologies, matches or match). +X: plugins.json icons entries are ConfiguredIconPack; one entry parser (selections) serves the workSources and icons sections with the same messages as before; requireUniqueIds covers scanners, work sources and icon packs, and the parser's own duplicate check for icon ids is gone. ResolvedIconPackage has no entry and no include: entry moved from the shared fields to the scanner and work-source packages. The id, name and version checks run once for every kind (packageIdentity) and the unsupported-field check covers groma.icons too. The file resolver takes a label, so a wrong icon path is reported as an icon and not as an entry; with the same label for entries, one message of TASK-568 changed by one word (plugin package entry must be a file became plugin package.json entry must be a file). saveIconPack with its replacing flag became addIconPack and replaceIconPackSource; the replace path lost the id checks that requireUniqueIds makes unreachable. Official is decided by officialIconPackIds from the embedded pack manifest. docs/scanners/setup.md names the icons section and docs/icons.md says a pack declares no entry and cannot reuse an id. +Checks: bun run typecheck passed; bunx biome lint unchanged (1 warning, 2 infos, none in a changed file); 63 bun tests in 16 plugin and scanner-settings files passed; the new unique-id test failed before the config change. On a scratch project: plugin list shows the pack, adding it again is refused, a pack with a path that is not ./relative or with an unsupported field is refused with an icon-worded message. + +Accent tint decision. An accented surface is now its accent mixed into the theme's own paper, 22 percent in every theme (ACCENT_TINT in iso/painting/scale.ts), instead of a fixed mix with white; accentTint is gone from src/element-appearance.ts. The element's group carries only the accent, as the color attribute (withAccent). accentRules in iso/painting/style.ts owns the colours for both renderers: stroke and title take currentColor; the browser fill is color-mix(in srgb, currentColor 22%, var(--paper)), so it follows the live theme; a cover gets one explicit fill rule per accent of its scene (sceneAccents), computed with mixColour from the palette. Measured first: resvg paints a rule that uses color-mix or var black, and it reads the [color=...] attribute selector, child and descendant combinators and currentColor. The accent travels as the color attribute rather than as a custom property, because resvg reads the first and not the second, so one carrier serves the browser and covers. The stored field, the colour names, the accent stroke and the title colour are unchanged; in the light theme, where paper is white, surfaces look as before. +Checks: bun run typecheck passed; bunx biome lint unchanged (1 warning, 2 infos, none in a changed file); bun scripts/lint-web-scrollbars.ts passed; bun test --parallel=2 --max-concurrency 1 --timeout 20000 on 37 focused files passed, 183 tests; node tests 16 of 16. The cover test, extended to the dark cover, failed before the change. Headless Chromium on an exported scratch project in light, dark and blueprint: titles of unaccented children on an accented system surface are readable in all three; a selected accented container and its context system take the highlight outline and title; accented drafts stay hollow and dashed in their accent; with a flow active, the accented components on the path take the highlight and the others dim; in a comparison of two commits, modified accented components take the change colour for fill, outline and title. Covers in the three themes match the browser, show pack icons and hide emoji. Hover over an accent was not exercised (no pointer in the headless run); its rule is more specific than the accent rules. + +Final round. Merged the finished plugins.json branch (remediate/117 at 550b2fdd, merge commit 6b4f35d7). Icon packs are now a third kind inside its generic functions: selections, withAdded, removePlugin and updatePlugin in src/plugin-management.ts each gained the icons section, restore and install reach packs through restorePluginPackage, and groma plugin list prints them. addIconPack, replaceIconPackSource, restoreIconPacks, the inline removal and every hand-written id check for icons are gone; writePluginConfig validates through the parser, where requireUniqueIds covers the three sections. PluginConfig.icons is a required array written in the fixed key order, so every config literal in tests and in scripts/reproduce-scanner-boundaries.ts names it. src/icon-packs.ts keeps the pack inventory, the catalog, resolveIcon (the one place that decides pack name or emoji, used by the loader and by lint) and unknownIcons; isEmoji and loadIconCatalog are private and a pack's files are validated only in package.ts. +Accent titles: an accented title is the accent mixed 45 percent toward the theme's ink (ACCENT_TITLE), through color-mix with var(--ink) in the browser and mixColour in covers. Measured contrast of the title on its own surface, light / dark / blueprint: red 5.85 6.00 6.12, orange 5.02 6.72 6.88, amber 5.01 6.73 6.86, yellow 4.68 6.93 7.15, green 5.41 6.40 6.47, teal 5.57 6.35 6.36, cyan 5.51 6.30 6.40, blue 5.73 6.26 6.34, indigo 6.14 6.04 6.13, violet 5.87 6.24 6.28, purple 6.10 6.02 6.14, pink 5.74 6.18 6.27, brown 5.89 6.30 6.42, grey 5.56 6.44 6.57. The smallest share that reaches 4.5 everywhere is 0.43; 0.45 leaves a margin. Strokes keep the plain accent. +Cold review: Building.lines is the title again and the icon row is reserved in roofBlock and footprintOf; the icon reserve is one constant, ICON_ADVANCE, from which the name cells are derived; the label fit test runs with and without an icon; renderArchitectureDocument lost its unused icon and colour parameters; isAccentColour is inlined; the edit error names --icon and --colour; lint and edit descriptions name the new rule and flags; the comparison test checks each field alone; the three icon-pack tests moved to test-bun/icon-packs.test.ts and the id test of TASK-568 was extended instead of repeated; every settings row that is not a scanner is summarised as Plugin details; packageIdentity runs once. In a cover a hidden emoji still keeps its room (the line above a building's name, the icon's width before a surface name); cover.ts and docs/icons.md say so. No component owns releasing packages, because release scripts are excluded from scans, so the stand-alone icon-pack-release component was folded into icon-packs with groma edit --combine after its overview was cleared; the package, icon-packs and src-lint-command records were updated with groma edit. +Checks: bun run typecheck passed; bunx biome lint 1 warning and 2 infos, the same as before the task's remediation, none in a changed file; bun scripts/lint-web-scrollbars.ts passed; bun test --parallel=2 --max-concurrency 1 --timeout 20000 on 47 focused files, 236 passed; node tests 16 of 16. The contrast test fails with a share of 0. tui-test on a scratch project at 120x36 and 200x60: cards with a pack icon or an emoji have no empty row, the details pane shows Icon and Colour rows, and the Plugins screen lists the pack under Icon packs with remove and update. Headless Chromium in light, dark and blueprint on an exported scratch project: surfaces, lighter or darker titles, selection, hollow dashed drafts, flow and comparison; the three covers match. Not run: the full bun run check, groma scan (no source file was added, moved, renamed or deleted), the web settings dialog in a browser, hover, and any publication. + +Orchestrator verification, 11 October 2026, stacked on the finished plugins.json branch with main merged: bun run check exit 0 (16 Node tests; 798 Bun tests passed, 52 skipped for missing optional toolchains, 0 failed). I inspected the implementer's dark-theme screenshots of accented systems, containers, components and drafts. Decisions taken in this pass, for the owner to confirm or reverse: an emoji icon is drawn as text and nothing is downloaded when the architecture loads, so cover images show pack icons only; an accented surface is the accent mixed into the theme's paper and its title the accent mixed toward the theme's ink, so every named accent reaches a contrast of 4.5 in light, dark and blueprint. The task stays In Progress on purpose: acceptance criterion 6 and Definition of Done 1 need @groma/icons-architecture on the public registry, which happens in the next release. + + +## Final Summary + + +Elements can carry an icon and a colour. Both are groma fields on the existing concept: written by groma edit --icon --colour and the web details form, validated by the model, preserved by scans, protected by combine, and reported by revision comparisons. Icon packs are the third plugin kind in plugins.json; the generic plugin commands and settings add, remove, update and restore them, and a pack resolves to its SVG files without any code to run. An icon is a pack image, an emoji drawn as text, or an unknown name that groma lint reports; loading never touches the network. On the web map an accent draws the element's outline, tints its own surfaces with the theme's paper and darkens or lightens its title toward the theme's ink, below selection, flow, comparison and draft states, in the browser and in covers alike. The details panes and the legend show the values; the terminal map is unchanged. The official twelve-icon pack is staged and published with the scanner packages. + +Verified here: type check, lint without a new warning, 236 focused bun tests in 47 files and 16 node tests, tui-test for a terminal card with an icon and the Plugins screen, headless Chromium in the three themes for surfaces, titles, selection, draft, flow and comparison, and the three covers. Not verified: the full repository check (run by the orchestrator), hover over an accent, and any npm publication, so AC6 and DoD 1 stay unchecked. + +Known limits: a cover has no emoji font, so it hides an emoji icon and keeps its room empty; the details pane heading uses the plain accent; an icon pack is official by its id, like a work source. + diff --git a/bun.lock b/bun.lock index 41bcb6c0..6cd35602 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,10 @@ "name": "@groma/work-source", "version": "0.1.0", }, + "plugins/icons/architecture": { + "name": "@groma/icons-architecture", + "version": "0.1.0", + }, "plugins/scanners/angular": { "name": "@groma/scanner-angular", "version": "0.2.2", @@ -270,6 +274,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@groma/icons-architecture": ["@groma/icons-architecture@workspace:plugins/icons/architecture"], + "@groma/scanner": ["@groma/scanner@workspace:packages/scanner"], "@groma/scanner-angular": ["@groma/scanner-angular@workspace:plugins/scanners/angular"], diff --git a/docs/agent-instructions/describe.md b/docs/agent-instructions/describe.md index 2b2c1d21..d2fc3fe8 100644 --- a/docs/agent-instructions/describe.md +++ b/docs/agent-instructions/describe.md @@ -27,12 +27,12 @@ of writing guesses. Optional fields need content only when they add meaning. | Command | Target | Options | | --- | --- | --- | -| `groma edit ` | an element ID: actor, external system, system, container, or component | `--title`, `--description`, `--overview`, `--technology` | +| `groma edit ` | an element ID: actor, external system, system, container, or component | `--title`, `--description`, `--overview`, `--technology`, `--icon`, `--colour` | | `groma edit ` | a draft ID | `--title`, `--overview` | | `groma edit project` | the word `project`, meaning the project record | `--title`, `--description`, `--overview` | -`--title` renames the record; its ID stays. An empty `--description` or -`--technology` value removes that field, and an empty `--overview` clears the +`--title` renames the record; its ID stays. An empty `--description`, +`--technology`, `--icon` or `--colour` value removes that field, and an empty `--overview` clears the body. `groma view` has no `project` target: the project record is `/project.md`. Read that file, and change it only through `groma edit project`. diff --git a/docs/agent-instructions/inspect.md b/docs/agent-instructions/inspect.md index 42188220..4c06fe73 100644 --- a/docs/agent-instructions/inspect.md +++ b/docs/agent-instructions/inspect.md @@ -21,7 +21,7 @@ add descriptions, actors, flows, groups, or relationship rows. | | a draft ID | Prints the draft's outcome and the elements it touches. | | | an exact repository-relative source file | Prints the owning component's ID, kind, title, and parent, the file connections of map relationships split into incoming and outgoing, and the command for the owner's complete record. Rows between files of one component are not listed; `groma view --plain` lists relationships that name the owner element. | | `groma scanner list` | none | Lists the configured scanners and whether each is ready. | -| `groma lint` | none | Pages possible duplicate logic from fresh scanner evidence. Findings are review questions: they are not relationships and do not merge components. | +| `groma lint` | none | Pages possible duplicate logic from fresh scanner evidence, then each element whose icon no installed pack resolves. Findings are review questions: they are not relationships and do not merge components. | A relationship line reads `source -> target | description | technology`, with a trailing `| draft` on a draft relationship. The overview lifts each end to diff --git a/docs/agent-instructions/structure.md b/docs/agent-instructions/structure.md index 2b04118a..30f2d5ca 100644 --- a/docs/agent-instructions/structure.md +++ b/docs/agent-instructions/structure.md @@ -97,7 +97,8 @@ combine instead collects the absorbed components' Code references on the surviving component. Combined records must have the same kind and parent. Absorbed records cannot -have body content, concept-addressed relationships, a group, or technology. +have body content, concept-addressed relationships, a group, technology, an +icon, or a colour. The children a combine absorbs directly cannot have body content either, so a system combine refuses a described container while a described component two levels down relocates with it. A component move requires an empty body; a diff --git a/docs/architecture-findings.md b/docs/architecture-findings.md index 820e1ee9..0d2d7a65 100644 --- a/docs/architecture-findings.md +++ b/docs/architecture-findings.md @@ -5,7 +5,9 @@ Run `groma lint` to check for architecture issues. The first rule reports possible duplicate logic, with operation names and source file:line locations. It uses current evidence from configured, installed scanners and respects project -exclusions. It does not write architecture records or change scanner selections. +exclusions. A second rule lists each element whose `icon` is neither an icon of +an installed pack nor an emoji. The command does not write architecture records +or change scanner selections. The command exits with code 1 when it finds issues or a scanner fails, and 0 otherwise. An empty result applies only to the available scanner evidence; diff --git a/docs/component-markdown.md b/docs/component-markdown.md index 880dbdeb..998f121f 100644 --- a/docs/component-markdown.md +++ b/docs/component-markdown.md @@ -166,6 +166,8 @@ groma.md-only fields live together under `groma`: | `draft` | no | ID of the draft record this concept belongs to or that touches it. | | `group` | no | Readable name of a hand-authored sibling cluster. | | `technology` | no | Free text naming implementation technology, comma-separated. | +| `icon` | no | An installed icon name (optionally `pack-id:name`) or one emoji. | +| `colour` | no | Theme-independent accent: red, orange, amber, yellow, green, teal, cyan, blue, indigo, violet, purple, pink, brown, grey, or `#RRGGBB`. | | `code` | no | Scanner-produced source evidence. | There is no `kind` field. The standard `type` carries the C4 type, and the @@ -444,3 +446,16 @@ TypeScript, NestJS, and PostgreSQL. The live [groma.md system](../groma/systems/groma/system.md) and [MVP draft](../groma/drafts/mvp.md) are a complete package example. + +## Element appearance + +`groma edit --icon --colour ` and the web details form +set the optional `groma.icon` and `groma.colour` fields. Empty values remove +them. Scans preserve both fields. These are Groma presentation semantics on +the existing C4 element, not C4 types or OKF requirements. + +A colour name and its `#RRGGBB` value are the same colour. `groma edit +--combine` refuses to absorb a record that has an icon or a colour, as it does +for a group or technology. A revision comparison reports a changed icon or +colour as a modification of its component. +See [icon packs and colours](icons.md) for drawing, installation and publishing. diff --git a/docs/icons.md b/docs/icons.md new file mode 100644 index 00000000..e18842e7 --- /dev/null +++ b/docs/icons.md @@ -0,0 +1,90 @@ +# Architecture icons and colours + +Give any architecture element an icon and a fixed accent through its details +form or the CLI. These are Groma presentation fields on the existing OKF +concept, not C4 types or additional architecture boundaries. Ordinary Markdown +readers retain the concept's title, prose, links and readable field values. + +```sh +groma plugin add @groma/icons-architecture +groma edit storage --icon architecture:database --colour blue +groma edit storage --icon "📦" --colour "#397ed1" +groma edit storage --icon "" --colour "" +``` + +Colours accept the names documented in [the Markdown contract](component-markdown.md) +or six-digit hexadecimal RGB. An accent keeps its colour in light, dark and +blueprint themes and draws the element's outline. The element's surfaces are +the theme's paper mixed with the accent, and its title is the accent mixed +toward the theme's text colour, so titles stay readable on those surfaces in +every theme. The legend below the hierarchy lists each colour in use with +the number of elements that have it; a name and its hexadecimal value are the +same colour. + +Icon names resolve through installed packs in configured order. Use `pack:name` +to select a particular pack. An unknown name draws no icon and `groma lint` +reports the element and name. A resolved pack icon travels with the map as its +SVG, so an exported page or a cover shows it without the pack and without +network access. An emoji needs no pack: the map draws it as text in the +reader's own emoji font. Covers show pack icons only, because the cover +renderer has no emoji font; the room of a hidden emoji stays, so a building's +name sits one line lower and a system's or container's name slightly right of +centre. The details panes list the icon's name, or the +emoji, and the colour; the web pane also draws a pack icon beside the title. + +## Make a pack + +A pack is a normal npm package with SVG files and this package.json metadata: + +```json +{ + "name": "my-architecture-icons", + "version": "1.0.0", + "files": ["icons"], + "groma": { + "icons": { + "id": "my-icons", + "icons": { "database": "./icons/database.svg" } + } + } +} +``` + +The pack id and icon names use lowercase kebab-case. Each value names an SVG +file inside the package. Use self-contained SVG with a viewBox, explicit +colours, and vector shapes; external files and fonts are not bundled. A pack +declares no entry: Groma reads only the SVG files it declares, as images, and +runs no code from it. + +Install a development pack with `groma plugin add ./path/to/pack`. An npm or +pinned Git pack is installed like any plugin package, so the dependencies its +package.json declares are downloaded too, with install scripts disabled: +`groma plugin add my-architecture-icons@1.0.0` or +`groma plugin add git+https://example.com/icons.git#v1.0.0`. +Selections live in the `icons` array of `/plugins.json`, in the +order they answer an icon name that names no pack. A pack id cannot repeat the +id of another pack, a scanner or a work source: + +```json +{ "scanners": [], "workSources": [], "icons": [ + { "id": "my-icons", "source": "my-architecture-icons@1.0.0" } +] } +``` + +`groma plugin list`, `remove`, `install` and `update` manage the selection; +Settings lists installed icon packs alongside scanners and work sources. + +## Publish + +Publish a third-party pack using `npm publish --access public` after choosing +its package name, version and licence. Include every declared SVG in `files`. + +The official pack is `plugins/icons/architecture`. It supplies database, queue, +cache, browser, mobile-app, service, scheduler, file-store, mail, identity, +gateway and search. Its build function copies the manifest, SVGs and project +licence; the existing scanner release workflow stages it as `icons-architecture` +on each host, carries it through assembly and publishes it with the scanner +packages. It needs no platform-specific build. Follow the existing +[release procedure](scanners/publishing.md), bumping its version when its +contents change. Publication requires the same npm access or trusted-publisher +configuration as other official packages. diff --git a/docs/scanners/publishing.md b/docs/scanners/publishing.md index 3bd01a03..63605a9b 100644 --- a/docs/scanners/publishing.md +++ b/docs/scanners/publishing.md @@ -143,3 +143,7 @@ home and only Git on PATH, checks useful local facts, repeats the scan and compares source bytes. The JavaScript harness rejects network fetches. This is evidence for the exercised host, not a claim of testing every target. See [fresh-checkout validation](fresh-checkout-validation.md). + +The release also stages and publishes the platform-independent +`@groma/icons-architecture` pack. See [icon packs](../icons.md) for its manifest, +assets, and third-party publishing contract. diff --git a/docs/scanners/setup.md b/docs/scanners/setup.md index 6eed718e..8db1eef9 100644 --- a/docs/scanners/setup.md +++ b/docs/scanners/setup.md @@ -2,10 +2,10 @@ `/plugins.json` explicitly selects project plugins. A missing file selects none. No plugin is loaded just because its package or CLI is installed. -The file has one section per kind: `scanners` and `workSources`. This revision -supports at most one work source. Plugin ids are lowercase kebab-case, and one id -names one plugin across both sections. `exclude` and `useGitignore` apply to -scanners at the top level. +The file has one section per kind: `scanners`, `workSources` and `icons`. This +revision supports at most one work source. Plugin ids are lowercase kebab-case, +and one id names one plugin across all sections. `exclude` and `useGitignore` +apply to scanners at the top level. ```json { @@ -21,7 +21,8 @@ scanners at the top level. ``` Scanner entries require `id`, `source`, and `include`, and may contain `settings` -and `exclude`. Work-source entries contain only `id` and `source`. Sources use +and `exclude`. Work-source and [icon-pack](../icons.md) entries contain only +`id` and `source`. Sources use an exact npm `package@version`, a local `./path`, or a `git+https://repository#tag-or-commit`; adding a bare npm name selects a compatible stable release and stores its exact version. Git tags are pinned to a commit. @@ -34,7 +35,7 @@ groma plugin update backlog groma plugin remove backlog ``` -These commands manage both kinds; scanner commands remain available for scanner +These commands manage every kind; scanner commands remain available for scanner setup, discovery, readiness, and existing scanner selections. Web and terminal settings group selections by kind. Package availability and readiness are separate: a missing package needs restoration; an incompatible Groma version diff --git a/groma/systems/groma-md/containers/cli/components/icon-packs.md b/groma/systems/groma-md/containers/cli/components/icon-packs.md new file mode 100644 index 00000000..bac05e9d --- /dev/null +++ b/groma/systems/groma-md/containers/cli/components/icon-packs.md @@ -0,0 +1,17 @@ +--- +type: C4 Component +title: Icon packs +status: stable +groma: + id: icon-packs + parent: cli + code: + - scanner: typescript + file: src/icon-packs.ts + - scanner: typescript + file: plugins/icons/architecture/build.ts + symbol: buildPackage +description: Loads selected SVG icon packs and resolves element icons for maps and lint +--- + +Reads the icon packs selected in plugins.json through the shared package resolver and reports what this computer has of each. Resolves qualified or unqualified names to embedded SVG images, leaves an emoji as text for each viewer to draw, and reports unresolved names to lint. Plugin management adds, updates, removes and restores packs like every other plugin kind. Element metadata remains in ordinary architecture Markdown; image data exists only in the loaded view. The build script of the official architecture pack copies its manifest, SVG artwork and licence into the release staging directory. diff --git a/groma/systems/groma-md/containers/cli/components/modules-settings.md b/groma/systems/groma-md/containers/cli/components/modules-settings.md index e045d756..c41051c3 100644 --- a/groma/systems/groma-md/containers/cli/components/modules-settings.md +++ b/groma/systems/groma-md/containers/cli/components/modules-settings.md @@ -16,4 +16,4 @@ groma: description: Builds shared settings state and applies plugin actions by kind --- -Combines scanner discovery and readiness with the explicitly selected work source. The browser and terminal settings screens group plugins by kind and use the same add, update, remove and restore operations. Scanner recommendations remain scanner-specific. +Combines scanner discovery and readiness with the explicitly selected work source and icon packs. The browser and terminal settings screens group plugins by kind and use the same add, update, remove and restore operations. Scanner recommendations remain scanner-specific. diff --git a/groma/systems/groma-md/containers/cli/components/package.md b/groma/systems/groma-md/containers/cli/components/package.md index 4d2892dc..60c0d00c 100644 --- a/groma/systems/groma-md/containers/cli/components/package.md +++ b/groma/systems/groma-md/containers/cli/components/package.md @@ -20,7 +20,7 @@ groma: description: Selects, resolves, installs and restores project plugins by kind --- -Reads explicit plugin selections from plugins.json and manages their package lifecycle. Resolves npm, Git and local sources for scanners and work sources; stores pinned sources and scanner defaults. Plugin commands, scanner commands and settings share one add, remove, update and restore operation addressed by plugin id. Resolving a package never runs plugin code; work-source loading checks host compatibility before it imports the adapter and asks its readiness. +Reads explicit plugin selections from plugins.json and manages their package lifecycle. Resolves npm, Git and local sources for scanners, work sources and icon packs; stores pinned sources and scanner defaults. Plugin commands, scanner commands and settings share one add, remove, update and restore operation addressed by plugin id. Resolving a package never runs plugin code; work-source loading checks host compatibility before it imports the adapter and asks its readiness. An icon pack has no code to run: its package resolves to the SVG file of each icon it declares. ## Relationships diff --git a/groma/systems/groma-md/containers/cli/components/src-architecture-model.md b/groma/systems/groma-md/containers/cli/components/src-architecture-model.md index a09fe98f..780edd0c 100644 --- a/groma/systems/groma-md/containers/cli/components/src-architecture-model.md +++ b/groma/systems/groma-md/containers/cli/components/src-architecture-model.md @@ -22,6 +22,8 @@ groma: - scanner: typescript file: src/source-index.ts symbol: sourceIndex + - scanner: typescript + file: src/element-appearance.ts group: Architecture records description: Builds the in-memory C4 model from stored Markdown records --- diff --git a/groma/systems/groma-md/containers/cli/components/src-lint-command.md b/groma/systems/groma-md/containers/cli/components/src-lint-command.md index 9b9dc4b2..9a9b2c17 100644 --- a/groma/systems/groma-md/containers/cli/components/src-lint-command.md +++ b/groma/systems/groma-md/containers/cli/components/src-lint-command.md @@ -10,7 +10,7 @@ groma: file: src/lint-command.ts symbol: registerLintCommand group: Project commands -description: Reports possible duplicate logic from current scanner evidence +description: Reports possible duplicate logic and unknown icon names --- -Registers groma lint and checks current scanner evidence for possible duplicate logic. Reports findings and scan failures through the command exit status without changing saved architecture. +Registers groma lint. Checks current scanner evidence for possible duplicate logic and lists the elements whose icon no installed pack resolves. Reports findings and scan failures through the command exit status without changing saved architecture. diff --git a/groma/systems/groma-md/containers/export/components/map.md b/groma/systems/groma-md/containers/export/components/map.md index 9e0f0004..673bae75 100644 --- a/groma/systems/groma-md/containers/export/components/map.md +++ b/groma/systems/groma-md/containers/export/components/map.md @@ -27,8 +27,10 @@ groma: file: src/viewers/web/iso/painting/glow.ts - scanner: typescript file: src/viewers/web/iso/painting/layer-planes.ts + - scanner: typescript + file: src/viewers/web/iso/painting/appearance.ts group: Map drawing description: Paints the browser map as SVG buildings, routes and labels --- -Draws the map surfaces, buildings, labels, and connections as SVG. Applies selection and task emphasis. +Draws the map surfaces, buildings, labels, and connections as SVG. Applies selection and task emphasis. Draws each resolved icon, a pack's SVG image or an emoji as text, and fixed authored accent colours through the same painting functions used by the browser and cover exporter. diff --git a/groma/systems/groma-md/containers/export/components/shell.md b/groma/systems/groma-md/containers/export/components/shell.md index e6102e3a..13bbf7bd 100644 --- a/groma/systems/groma-md/containers/export/components/shell.md +++ b/groma/systems/groma-md/containers/export/components/shell.md @@ -20,8 +20,11 @@ groma: file: src/viewers/web/chrome/credits.ts - scanner: typescript file: src/viewers/web/chrome/frame.ts + - scanner: typescript + file: src/viewers/web/chrome/colour-legend.ts + symbol: paintColourLegend group: Browser controls description: Browser toolbar, side panels and keyboard shortcuts --- -Shows the map toolbar and side panels. Handles keyboard shortcuts, panel expansion and empty map states, and measures the frame the visible chrome leaves for the camera. +Shows the map toolbar and side panels. Handles keyboard shortcuts, panel expansion and empty map states, and measures the frame the visible chrome leaves for the camera. The map legend lists authored accent colours and the number of elements using each, and hides those rows when no colours are assigned. diff --git a/package.json b/package.json index d6319763..8cab60a2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "workspaces": [ "packages/*", "plugins/scanners/*", + "plugins/icons/*", "plugins/work-sources/*" ], "bin": { diff --git a/plugins/icons/architecture/build.ts b/plugins/icons/architecture/build.ts new file mode 100644 index 00000000..dd71a88d --- /dev/null +++ b/plugins/icons/architecture/build.ts @@ -0,0 +1,12 @@ +import { cp, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Platform-independent SVG package, staged alongside the official scanner packages. */ +export async function buildPackage(output: string): Promise { + const source = fileURLToPath(new URL('.', import.meta.url)) + await mkdir(output, { recursive: true }) + await cp(path.join(source, 'icons'), path.join(output, 'icons'), { recursive: true }) + await cp(path.join(source, 'package.json'), path.join(output, 'package.json')) + await cp(fileURLToPath(new URL('../../../LICENSE', import.meta.url)), path.join(output, 'LICENSE')) +} diff --git a/plugins/icons/architecture/icons/browser.svg b/plugins/icons/architecture/icons/browser.svg new file mode 100644 index 00000000..f2a387f1 --- /dev/null +++ b/plugins/icons/architecture/icons/browser.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/cache.svg b/plugins/icons/architecture/icons/cache.svg new file mode 100644 index 00000000..ab090da3 --- /dev/null +++ b/plugins/icons/architecture/icons/cache.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/database.svg b/plugins/icons/architecture/icons/database.svg new file mode 100644 index 00000000..86cc452c --- /dev/null +++ b/plugins/icons/architecture/icons/database.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/file-store.svg b/plugins/icons/architecture/icons/file-store.svg new file mode 100644 index 00000000..c84963a3 --- /dev/null +++ b/plugins/icons/architecture/icons/file-store.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/gateway.svg b/plugins/icons/architecture/icons/gateway.svg new file mode 100644 index 00000000..c2849a89 --- /dev/null +++ b/plugins/icons/architecture/icons/gateway.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/identity.svg b/plugins/icons/architecture/icons/identity.svg new file mode 100644 index 00000000..0d7e86ea --- /dev/null +++ b/plugins/icons/architecture/icons/identity.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/mail.svg b/plugins/icons/architecture/icons/mail.svg new file mode 100644 index 00000000..b1f43378 --- /dev/null +++ b/plugins/icons/architecture/icons/mail.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/mobile-app.svg b/plugins/icons/architecture/icons/mobile-app.svg new file mode 100644 index 00000000..6040b3b6 --- /dev/null +++ b/plugins/icons/architecture/icons/mobile-app.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/queue.svg b/plugins/icons/architecture/icons/queue.svg new file mode 100644 index 00000000..0edd0fa7 --- /dev/null +++ b/plugins/icons/architecture/icons/queue.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/scheduler.svg b/plugins/icons/architecture/icons/scheduler.svg new file mode 100644 index 00000000..abcb5ce2 --- /dev/null +++ b/plugins/icons/architecture/icons/scheduler.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/search.svg b/plugins/icons/architecture/icons/search.svg new file mode 100644 index 00000000..2a623162 --- /dev/null +++ b/plugins/icons/architecture/icons/search.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/icons/service.svg b/plugins/icons/architecture/icons/service.svg new file mode 100644 index 00000000..4e00415e --- /dev/null +++ b/plugins/icons/architecture/icons/service.svg @@ -0,0 +1 @@ + diff --git a/plugins/icons/architecture/package.json b/plugins/icons/architecture/package.json new file mode 100644 index 00000000..826022d9 --- /dev/null +++ b/plugins/icons/architecture/package.json @@ -0,0 +1,37 @@ +{ + "name": "@groma/icons-architecture", + "version": "0.1.0", + "description": "Generic architecture SVG icons for Groma.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/MrLesk/groma.md.git" + }, + "type": "module", + "files": [ + "icons", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "groma": { + "icons": { + "id": "architecture", + "icons": { + "database": "./icons/database.svg", + "queue": "./icons/queue.svg", + "cache": "./icons/cache.svg", + "browser": "./icons/browser.svg", + "mobile-app": "./icons/mobile-app.svg", + "service": "./icons/service.svg", + "scheduler": "./icons/scheduler.svg", + "file-store": "./icons/file-store.svg", + "mail": "./icons/mail.svg", + "identity": "./icons/identity.svg", + "gateway": "./icons/gateway.svg", + "search": "./icons/search.svg" + } + } + } +} diff --git a/scripts/reproduce-scanner-boundaries.ts b/scripts/reproduce-scanner-boundaries.ts index 4d64006f..3fb0c5d8 100644 --- a/scripts/reproduce-scanner-boundaries.ts +++ b/scripts/reproduce-scanner-boundaries.ts @@ -51,7 +51,7 @@ async function project(output: string, name: string, packages: string): Promise< scanners.push({ id, source: path.join(packages, id), ...manifest.groma.scanner.exclude && { exclude: manifest.groma.scanner.exclude }, include: manifest.groma.scanner.include }) } - await writePluginConfig(root, { scanners, workSources: [] }) + await writePluginConfig(root, { scanners, workSources: [], icons: [] }) return root } diff --git a/scripts/scanner-release.ts b/scripts/scanner-release.ts index 54c96ea3..2e20251b 100644 --- a/scripts/scanner-release.ts +++ b/scripts/scanner-release.ts @@ -5,6 +5,11 @@ import { assembleCSharpPackages } from './package-csharp-scanner.ts' const repository = { type: 'git', url: 'https://github.com/MrLesk/groma.md.git' } const scannerIds = ['nasm', 'cobol', 'java', 'scala', 'kotlin', 'go', 'rust', 'csharp', 'angular', 'vue', 'react', 'typescript', 'python', 'php', 'swift', 'javascript'] +/** + * Where the official icon pack is staged. It is an icon pack, not a scanner: no loop over scannerIds reaches it, + * and its own package.json already holds the release metadata that staging adds to scanner manifests. + */ +const iconPackDirectory = 'icons-architecture' async function manifest(directory: string) { return JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')) @@ -45,7 +50,7 @@ async function stage(output: string) { await mkdir(output, { recursive: true }) await stageSourcePackage('packages/scanner', path.join(output, 'contract')) await stageWorkSourcePackages(output) - const builders = { + const scannerBuilders = { php: (await import('../plugins/scanners/php/build.ts')).buildPackage, python: (await import('../plugins/scanners/python/build.ts')).buildPackage, typescript: (await import('../plugins/scanners/typescript/build.ts')).buildPackage, @@ -62,9 +67,11 @@ async function stage(output: string) { javascript: (await import('../plugins/scanners/javascript/build.ts')).buildPackage, swift: (await import('../plugins/scanners/swift/build.ts')).buildPackage, } + const buildIconPack = (await import('../plugins/icons/architecture/build.ts')).buildPackage await completeTogether([ - ...Object.entries(builders).map(([id, build]) => build(path.join(output, id))), + ...Object.entries(scannerBuilders).map(([id, build]) => build(path.join(output, id))), run([process.execPath, 'scripts/package-csharp-scanner.ts', path.join(output, 'csharp')]), + buildIconPack(path.join(output, iconPackDirectory)), ]) for (const id of scannerIds) { const directory = path.join(output, id) @@ -159,10 +166,14 @@ async function publish(input: string) { if (workSource.private || !workSource.groma.workSource.compatibility.groma) { throw new Error('backlog: confirm public name, version and compatibility before publishing') } + const iconPack = await manifest(path.join(input, iconPackDirectory)) + if (iconPack.private || !iconPack.groma?.icons?.icons) throw new Error('Icon pack requires public package metadata') await completeTogether(['contract', 'work-source-contract'].map(id => publishPackage(path.join(input, id)))) await publishPackage(path.join(input, 'work-source-backlog')) - await completeTogether(scannerIds.map(id => id === 'csharp' - ? publishCSharp(input) : publishPackage(path.join(input, id)))) + await completeTogether([ + ...scannerIds.map(id => id === 'csharp' ? publishCSharp(input) : publishPackage(path.join(input, id))), + publishPackage(path.join(input, iconPackDirectory)), + ]) } async function publishCSharp(input: string) { diff --git a/src/architecture-model.ts b/src/architecture-model.ts index edc8887a..c6a6114d 100644 --- a/src/architecture-model.ts +++ b/src/architecture-model.ts @@ -1,6 +1,7 @@ import { elementOverview } from './architecture-markdown.ts' import { isExternalPath } from './architecture-path.ts' import { codeReferencesOf } from './code-reference.ts' +import { accentColour } from './element-appearance.ts' import { isReservedId, kebabCase } from './naming.ts' import { DRAFT_TYPE, c4Kind, requireGromaMapping } from './okf-profile.ts' import { storedConnections } from './relationship-markdown.ts' @@ -56,7 +57,7 @@ function invalidElement(sourceFilename: string, message: string): never { function validateGromaFields(groma: Record, sourceFilename: string): void { const unknownFields = Object.keys(groma).filter(field => { - return !['id', 'parent', 'group', 'technology', 'code', 'draft'].includes(field) + return !['id', 'parent', 'group', 'technology', 'icon', 'colour', 'code', 'draft'].includes(field) }) if (unknownFields.length > 0) { invalidElement(sourceFilename, `unsupported groma field(s): ${unknownFields.join(', ')}`) @@ -65,7 +66,7 @@ function validateGromaFields(groma: Record, sourceFilename: str function optionalText( value: unknown, - field: 'description' | 'parent' | 'group' | 'technology' | 'draft', + field: 'description' | 'parent' | 'group' | 'technology' | 'icon' | 'colour' | 'draft', sourceFilename: string, ): string | undefined { if (value === undefined) return undefined @@ -138,6 +139,9 @@ function documentToElement(document: ArchitectureDocument): ArchitectureElement const parent = optionalText(groma.parent, 'parent', sourceFilename) const group = optionalText(groma.group, 'group', sourceFilename) const technology = optionalText(groma.technology, 'technology', sourceFilename) + const icon = optionalText(groma.icon, 'icon', sourceFilename) + const colour = optionalText(groma.colour, 'colour', sourceFilename) + if (colour !== undefined && accentColour(colour) === undefined) invalidElement(sourceFilename, 'colour must be a supported colour name or #RRGGBB') const draft = draftOf(groma, sourceFilename) return { @@ -152,6 +156,8 @@ function documentToElement(document: ArchitectureDocument): ArchitectureElement external, ...(group === undefined ? {} : { group }), ...(technology === undefined ? {} : { technology }), + ...(icon === undefined ? {} : { icon }), + ...(colour === undefined ? {} : { colour }), code: codeReferencesOf(groma.code, kind, message => { throw new ArchitectureModelError('INVALID_ELEMENT', sourceFilename, message) }), diff --git a/src/authoring-conflict.ts b/src/authoring-conflict.ts index d070513b..298d4b85 100644 --- a/src/authoring-conflict.ts +++ b/src/authoring-conflict.ts @@ -1,5 +1,5 @@ /** Values from the start of a web edit session. CLI commands overwrite the latest fields. */ -export type EditValues = Partial> +export type EditValues = Partial> /** Both web forms send only changed fields, each paired with its value when editing began. */ export function changedEdit(original: EditValues, values: EditValues): EditValues & { original: EditValues } { diff --git a/src/core.ts b/src/core.ts index 7c0f6380..5cbb46e8 100644 --- a/src/core.ts +++ b/src/core.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { architectureFindingsFor } from './architecture-findings.ts' import { buildArchitectureModel, draftRecordOf } from './architecture-model.ts' import { loadArchitecture } from './architecture-reader.ts' +import { attachElementIcons } from './icon-packs.ts' import { moveBlocker } from './movable.ts' import { resolveFlows } from './flow-model.ts' import type { @@ -80,6 +81,8 @@ export function annotateArchitecture( external: element.external, ...(element.group === undefined ? {} : { group: element.group }), ...(element.technology === undefined ? {} : { technology: element.technology }), + ...(element.icon === undefined ? {} : { icon: element.icon }), + ...(element.colour === undefined ? {} : { colour: element.colour }), code: element.code.map(reference => ({ ...reference, ...(counts.get(reference.file) ?? { dependencies: 0, dependents: 0 }) })), // The web pane offers a new parent only for a component the write would move. movable: element.kind === 'component' @@ -138,7 +141,7 @@ export async function loadAnnotatedArchitecture( options: { onFilesystemAccess?: FilesystemAccessHandler } = {}, ): Promise { const model = annotateArchitecture(await loadArchitecture(repositoryRoot, options)) - await attachCodeLines(repositoryRoot, model.elements) + await Promise.all([attachCodeLines(repositoryRoot, model.elements), attachElementIcons(repositoryRoot, model.elements)]) const findings = architectureFindingsFor(repositoryRoot) return findings.length === 0 ? model : { ...model, findings } } diff --git a/src/curate.ts b/src/curate.ts index f2c18440..0abe4e47 100644 --- a/src/curate.ts +++ b/src/curate.ts @@ -303,7 +303,7 @@ function requireCombinableSources( if (source.external !== target.external) { throw new Error('an external system and an internal system cannot combine') } - if (source.group !== undefined || source.technology !== undefined) { + if ([source.group, source.technology, source.icon, source.colour].some(field => field !== undefined)) { throw new Error(`cannot combine "${source.id}" because it has authored metadata`) } } diff --git a/src/edit.ts b/src/edit.ts index 569b7a07..b99d17a1 100644 --- a/src/edit.ts +++ b/src/edit.ts @@ -46,6 +46,15 @@ function optionalText(value: string | undefined): string | undefined { return value } +function hasAppearance(input: EditArchitectureInput): boolean { + return input.icon !== undefined || input.colour !== undefined +} + +function hasMeaning(input: EditArchitectureInput): boolean { + return hasAppearance(input) + || [input.title, input.overview, input.description, input.technology, input.draft].some(value => value !== undefined) +} + function isStructural(input: EditArchitectureInput): boolean { return input.group !== undefined || input.ungroup === true @@ -57,7 +66,7 @@ function isStructural(input: EditArchitectureInput): boolean { /** The project record: its title, description and overview, merged into the current profile. */ async function editProject(repositoryRoot: string, input: EditArchitectureInput): Promise { - if (isStructural(input) || input.draft !== undefined || input.technology !== undefined) { + if (isStructural(input) || input.draft !== undefined || input.technology !== undefined || hasAppearance(input)) { throw new Error('only --title, --description and --overview are valid on the project') } const overview = input.overview @@ -81,7 +90,7 @@ async function editDraftRecord( record: DraftRecord, input: EditArchitectureInput, ): Promise { - if (input.description !== undefined || input.technology !== undefined || input.draft !== undefined || isStructural(input)) { + if (input.description !== undefined || input.technology !== undefined || hasAppearance(input) || input.draft !== undefined || isStructural(input)) { throw new Error('only --title and --overview are valid on a draft record') } const overview = input.overview @@ -110,13 +119,13 @@ async function elementMeaning( function editAddressed(repositoryRoot: string, input: EditArchitectureInput): Promise | undefined { if (input.relation === undefined && isGroupAddress(input.id)) { if (input.relation !== undefined || input.overview !== undefined || input.description !== undefined - || input.technology !== undefined || input.draft !== undefined || isStructural(input)) { + || input.technology !== undefined || hasAppearance(input) || input.draft !== undefined || isStructural(input)) { throw new Error('only --title is valid on a group') } return editGroup(repositoryRoot, { address: input.id, title: input.title, original: input.original }) } if (input.relation !== undefined) { - if (input.title !== undefined || input.overview !== undefined || input.draft !== undefined || isStructural(input)) { + if (input.title !== undefined || input.overview !== undefined || hasAppearance(input) || input.draft !== undefined || isStructural(input)) { throw new Error('only --description and --technology are valid on a relation') } return editRelation(repositoryRoot, { @@ -137,6 +146,7 @@ export async function editArchitecture( const model = buildArchitectureModel(records.documents) const flow = records.flows.find(document => requireGromaMapping(document.frontmatter, document.sourceFilename).id === input.id) if (flow !== undefined) { + if (hasAppearance(input)) throw new Error('icon and colour apply only to architecture elements') if (input.newId !== undefined) throw new Error('--id renames systems, containers, and components') checkEdit(input.original, resolveFlows([flow], model)[0]!, input) return editFlow(repositoryRoot, records, flow, input) @@ -150,15 +160,16 @@ export async function editArchitecture( } checkEdit(input.original, { ...element, parent: element.parentId ?? '' }, input) - const hasMeaning = [input.title, input.overview, input.description, input.technology, input.draft].some(value => value !== undefined) - if (!hasMeaning && !isStructural(input)) throw new Error('--title, --overview, --description, --technology or --draft is required') - const source = hasMeaning ? await elementMeaning(repositoryRoot, records, element, input) : undefined + if (!hasMeaning(input) && !isStructural(input)) throw new Error('--title, --overview, --description, --technology, --icon, --colour or --draft is required') + const source = hasMeaning(input) ? await elementMeaning(repositoryRoot, records, element, input) : undefined if (isStructural(input)) { return curateElement(repositoryRoot, records, model, { id: input.id, title: input.title, technology: input.technology, + icon: input.icon, + colour: input.colour, draft: input.draft, overview: input.overview, description: input.description, diff --git a/src/element-appearance.ts b/src/element-appearance.ts new file mode 100644 index 00000000..4560e4bf --- /dev/null +++ b/src/element-appearance.ts @@ -0,0 +1,20 @@ +/** + * The named accents. An accent is presentation metadata and keeps its colour in every viewer theme. A new name must + * keep its title readable on its own surface in every theme; the map's ACCENT_TINT and ACCENT_TITLE shares decide that. + */ +export const accentColours: Readonly> = { + red: '#dc4545', orange: '#d97928', amber: '#b88a18', yellow: '#b99a18', + green: '#299764', teal: '#228e91', cyan: '#208cb4', blue: '#397ed1', + indigo: '#6864d4', violet: '#9261ce', purple: '#a351ba', pink: '#d34b8b', + brown: '#9a7052', grey: '#78838c', +} + +/** + * The colour an authored value stands for, as lower-case #rrggbb so one colour has one spelling. + * Undefined when the value is neither a name nor #RRGGBB. + */ +export function accentColour(value: string | undefined): string | undefined { + if (value === undefined) return undefined + if (Object.hasOwn(accentColours, value)) return accentColours[value] + return /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : undefined +} diff --git a/src/history/comparison.ts b/src/history/comparison.ts index c2c8d63f..cf17f146 100644 --- a/src/history/comparison.ts +++ b/src/history/comparison.ts @@ -21,9 +21,9 @@ export function ownedFiles(world: AnnotatedArchitectureModel): string[] { } function ownContent(element: AnnotatedElement): string { - const { title, description, overview, parent, external, group, technology, origin, draft } = element + const { title, description, overview, parent, external, group, technology, icon, colour, origin, draft } = element const code = element.code.map(({ scanner, file, symbol }) => JSON.stringify([scanner, file, symbol])).sort() - return JSON.stringify({ title, description, overview, parent, external, group, technology, origin, draft, code }) + return JSON.stringify({ title, description, overview, parent, external, group, technology, icon, colour, origin, draft, code }) } function componentChange(before: AnnotatedElement | undefined, after: AnnotatedElement | undefined, diff --git a/src/icon-packs.ts b/src/icon-packs.ts new file mode 100644 index 00000000..4cfac58f --- /dev/null +++ b/src/icon-packs.ts @@ -0,0 +1,87 @@ +import { readFile } from 'node:fs/promises' +import { readPluginConfig } from './scanner/modules/config.ts' +import { parsePluginSource, resolvePluginPackage, type PluginResolutionOptions, type ResolvedIconPackage } from './scanner/modules/package.ts' +import type { AnnotatedElement, ResolvedIcon } from './types.ts' + +interface IconPackSelectionFields { + id: string + source: string + kind: 'icons' + message: string +} + +/** A pack selected in plugins.json as it stands on this computer. The package is there exactly when the pack is found. */ +export type IconPackSelection = + | IconPackSelectionFields & { status: 'found'; pack: ResolvedIconPackage } + | IconPackSelectionFields & { status: 'missing' | 'blocked' } + +/** + * Every configured pack, in its configured order, with what this computer has of it. A pack that cannot be used + * (another id than configured, a manifest error, a declared icon without its SVG file) is blocked with the reason + * instead of failing the caller, so the map still opens; the plugin settings and `groma plugin list` show it. + */ +export async function iconPackInventory(root: string, options: PluginResolutionOptions = {}): Promise { + const config = await readPluginConfig(root) + return Promise.all(config.icons.map(async (selected): Promise => { + const base = { ...selected, kind: 'icons' as const } + try { + const pack = await resolvePluginPackage(parsePluginSource(root, selected.source), options, 'icons') + if (!pack) return { ...base, status: 'missing', message: '' } + if (pack.id !== selected.id) throw new Error(`Configured icon pack ${selected.id} resolves to ${pack.id}`) + return { ...base, status: 'found', message: '', pack } + } catch (error) { + return { ...base, status: 'blocked', message: error instanceof Error ? error.message : String(error) } + } + })) +} + +/** + * The icons of the found packs as SVG data URIs: each under `pack:name`, and under its bare name for the first + * configured pack that has it. A missing or blocked pack adds nothing, so its names stop resolving and + * `groma lint` lists the elements that use them. + */ +async function loadIconCatalog(root: string): Promise> { + const catalog = new Map() + for (const selected of await iconPackInventory(root)) { + if (selected.status !== 'found') continue + for (const [name, file] of Object.entries(selected.pack.icons)) { + const uri = `data:image/svg+xml;base64,${(await readFile(file)).toString('base64')}` + catalog.set(`${selected.id}:${name}`, uri) + if (!catalog.has(name)) catalog.set(name, uri) + } + } + return catalog +} + +/** One grapheme that is a pictograph, a flag or a keycap. */ +function isEmoji(value: string): boolean { + const segments = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(value)] + return segments.length === 1 && /\p{Extended_Pictographic}|\p{Regional_Indicator}|[0-9#*]️?⃣/u.test(value) +} + +/** What an icon value means: a pack icon when a pack has that name, else an emoji, else nothing a viewer can draw. */ +function resolveIcon(catalog: ReadonlyMap, icon: string): ResolvedIcon | undefined { + const uri = catalog.get(icon) + if (uri !== undefined) return { kind: 'image', uri } + return isEmoji(icon) ? { kind: 'emoji', text: icon } : undefined +} + +/** + * Says how each element's icon is drawn. A pack icon travels as its embedded SVG, so an export needs no + * installed pack. An emoji stays the text it is, and an unknown name is left unresolved for lint to report. + */ +export async function attachElementIcons(root: string, elements: AnnotatedElement[]): Promise { + if (!elements.some(element => element.icon)) return + const catalog = await loadIconCatalog(root) + for (const element of elements) { + const resolved = element.icon ? resolveIcon(catalog, element.icon) : undefined + if (resolved !== undefined) element.resolvedIcon = resolved + } +} + +/** One lint line for each element whose icon neither a found pack nor the emoji rule resolves. */ +export async function unknownIcons(root: string, elements: readonly { id: string; icon?: string }[]): Promise { + const catalog = await loadIconCatalog(root) + return elements.filter(element => element.icon && resolveIcon(catalog, element.icon) === undefined) + .map(element => `${element.id}: unknown icon "${element.icon}"`) +} diff --git a/src/instructions.ts b/src/instructions.ts index f7cc2a26..789181f4 100644 --- a/src/instructions.ts +++ b/src/instructions.ts @@ -26,7 +26,7 @@ source code ──scan──▶ groma|.groma/*.md ──view──▶ maps - groma add relation, groma edit relation, groma remove relation — author or reword a file interaction, or remove a draft interaction. Current relationships cannot be removed. - groma remove — take away a person, an external, a ghost, a code-free component or empty boundary, a draft nothing belongs to, a draft relation, or a group. 5. groma accept — accept a ghost only if a scan has matched it. The file stays where it is; only its status changes. -6. groma lint — report architecture issues from current scanner evidence. The first rule checks for possible duplicate logic. Saved architecture stays unchanged. Exit code 1 means findings or a scanner failure; 0 means no findings in the available evidence. +6. groma lint — report architecture issues from current scanner evidence. The first rule checks for possible duplicate logic; another lists icons that no installed pack resolves. Saved architecture stays unchanged. Exit code 1 means findings or a scanner failure; 0 means no findings in the available evidence. ## Rules of engagement diff --git a/src/lint-command.ts b/src/lint-command.ts index f2f9abbb..7f5a6b18 100644 --- a/src/lint-command.ts +++ b/src/lint-command.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander' import { architectureFindingItems, detectDuplicatedLogic } from './architecture-findings.ts' import { buildArchitectureModel } from './architecture-model.ts' import { loadArchitecture } from './architecture-reader.ts' +import { unknownIcons } from './icon-packs.ts' import { sourceIndex } from './source-index.ts' import { parseListWindow, printListPage, withListWindowOptions, type ListWindow } from './list-window.ts' import { loadScannerRegistry } from './scanner/registry.ts' @@ -14,17 +15,18 @@ async function lintArchitecture(repositoryRoot: string, window: ListWindow): Pro const registry = await loadScannerRegistry(repositoryRoot) const { observations, failures } = await registry.collectObservations(repositoryRoot) const findings = detectDuplicatedLogic(observations, owners) - printListPage(architectureFindingItems(findings), window) + const icons = await unknownIcons(repositoryRoot, model.elements) + printListPage([...architectureFindingItems(findings), ...icons], window) for (const failure of failures) console.error(failure.message) - if (!window.count && findings.length === 0 && failures.length === 0) { + if (!window.count && findings.length === 0 && icons.length === 0 && failures.length === 0) { console.log('No duplicate findings in available scanner evidence.') } - return findings.length > 0 || failures.length > 0 ? 1 : 0 + return findings.length > 0 || icons.length > 0 || failures.length > 0 ? 1 : 0 } export function registerLintCommand(program: Command): void { withListWindowOptions(program.command('lint') - .description('Report architecture issues (currently possible duplicate logic)')) + .description('Report architecture issues (possible duplicate logic and unknown icon names)')) .action(async options => { try { process.exitCode = await lintArchitecture(process.cwd(), parseListWindow(options, process.argv.slice(2))) diff --git a/src/markdown-emitter.ts b/src/markdown-emitter.ts index 85e86e91..1ae5fe88 100644 --- a/src/markdown-emitter.ts +++ b/src/markdown-emitter.ts @@ -19,6 +19,8 @@ export interface MeaningChanges { overview?: string description?: string technology?: string + icon?: string + colour?: string draft?: string } @@ -28,6 +30,8 @@ export function withMeaning(source: string, input: MeaningChanges): string { if (input.overview !== undefined) source = replaceLeadProse(source, input.overview) source = withDescription(source, input.description) if (input.technology !== undefined) source = withGromaField(source, 'technology', input.technology || undefined) + if (input.icon !== undefined) source = withGromaField(source, 'icon', input.icon || undefined) + if (input.colour !== undefined) source = withGromaField(source, 'colour', input.colour || undefined) if (input.draft !== undefined) source = withGromaField(source, 'draft', input.draft || undefined) return source } @@ -75,7 +79,7 @@ export function withGromaCode(source: string, code: CodeReference[]): string { export function withGromaField( source: string, - field: 'id' | 'group' | 'parent' | 'draft' | 'technology', + field: 'id' | 'group' | 'parent' | 'draft' | 'technology' | 'icon' | 'colour', value: string | undefined, ): string { return withGromaChange(source, groma => { diff --git a/src/plain-world.ts b/src/plain-world.ts index 3509c990..fa448889 100644 --- a/src/plain-world.ts +++ b/src/plain-world.ts @@ -137,6 +137,8 @@ function elementDetails(element: AnnotatedElement): string[] { const lines = [headerTokens(element).join(' ')] if (element.parent !== null) lines.push(`parent: ${element.parent}`) if (element.technology !== undefined) lines.push(`technology: ${element.technology}`) + if (element.icon !== undefined) lines.push(`icon: ${element.icon}`) + if (element.colour !== undefined) lines.push(`colour: ${element.colour}`) if (element.description !== undefined) lines.push(`description: ${element.description}`) if (element.overview !== '') lines.push(...element.overview.split(/\n\s*\n/)) return lines diff --git a/src/plugin-cli.ts b/src/plugin-cli.ts index de0aba03..8be0a5da 100644 --- a/src/plugin-cli.ts +++ b/src/plugin-cli.ts @@ -1,4 +1,5 @@ import type { Command } from 'commander' +import { iconPackInventory } from './icon-packs.ts' import { parseListWindow, printListPage, withListWindowOptions } from './list-window.ts' import { addPlugin, installPlugins, removePlugin, updatePlugin } from './plugin-management.ts' import { scannerInventory } from './scanner/modules/inventory.ts' @@ -23,7 +24,7 @@ function pluginLine(plugin: { kind: string; id: string; status: string; source: async function pluginInventory(root: string) { const scanners = (await scannerInventory(root)).map(scanner => ({ ...scanner, kind: 'scanner' })) const workSource = await checkWorkSourceReadiness(root) - return [...scanners, ...(workSource ? [workSource] : [])] + return [...scanners, ...(workSource ? [workSource] : []), ...await iconPackInventory(root)] } export function registerPluginCommands(program: Command): void { diff --git a/src/plugin-management.ts b/src/plugin-management.ts index 36f60711..c186ccec 100644 --- a/src/plugin-management.ts +++ b/src/plugin-management.ts @@ -32,6 +32,7 @@ function selections(config: PluginConfig, kind?: PluginKind): { kind: PluginKind return [ ...config.scanners.map(entry => ({ kind: 'scanner' as const, entry })), ...config.workSources.map(entry => ({ kind: 'workSource' as const, entry })), + ...config.icons.map(entry => ({ kind: 'icons' as const, entry })), ].filter(selected => kind === undefined || selected.kind === kind) } @@ -44,6 +45,8 @@ function selection(config: PluginConfig, id: string, kind?: PluginKind) { /** The config with a newly installed package selected in the section of its kind. */ function withAdded(config: PluginConfig, source: string, resolved: ResolvedPluginPackage): PluginConfig { if (resolved.kind === 'workSource') return { ...config, workSources: [...config.workSources, { id: resolved.id, source }] } + // A pack joins after the configured ones, which keep answering the icon names they share with it. + if (resolved.kind === 'icons') return { ...config, icons: [...config.icons, { id: resolved.id, source }] } // A scanner entry starts with the include globs and default exclusions its package declares; people edit them afterwards. const scanner = { id: resolved.id, source, include: resolved.include, ...(resolved.exclude?.length ? { exclude: resolved.exclude } : {}) } return { ...config, scanners: [...config.scanners, scanner] } @@ -71,6 +74,7 @@ export async function removePlugin(root: string, id: string, kind?: PluginKind): ...config, scanners: config.scanners.filter(entry => entry.id !== id), workSources: config.workSources.filter(entry => entry.id !== id), + icons: config.icons.filter(entry => entry.id !== id), }) return id } @@ -88,6 +92,7 @@ export async function updatePlugin( ...config, scanners: config.scanners.map(entry => entry.id === id ? { ...entry, source: installed.source } : entry), workSources: config.workSources.map(entry => entry.id === id ? { ...entry, source: installed.source } : entry), + icons: config.icons.map(entry => entry.id === id ? { ...entry, source: installed.source } : entry), }) return { id, kind: selected.kind, source: installed.source, status: 'found' } } diff --git a/src/scanner/modules/config.ts b/src/scanner/modules/config.ts index 301a3d67..a15dbddc 100644 --- a/src/scanner/modules/config.ts +++ b/src/scanner/modules/config.ts @@ -18,11 +18,18 @@ export interface ConfiguredWorkSource { source: string } +export interface ConfiguredIconPack { + id: string + source: string +} + /** plugins.json as the application holds it: every section is present, empty when the project selects nothing of that kind. */ export interface PluginConfig { scanners: ConfiguredScanner[] /** At most one in this revision. */ workSources: ConfiguredWorkSource[] + /** In the order the packs answer an icon name that names no pack. */ + icons: ConfiguredIconPack[] /** Git ignore patterns every scanner leaves out; Groma never writes them. */ exclude?: string[] /** Whether files Git ignores stay out of every scan; true unless set to false. */ @@ -100,25 +107,30 @@ function configuredScanner( } } -function configuredWorkSources(value: unknown, filename: string): ConfiguredWorkSource[] { +/** The entries of a section that selects each plugin by its id and source alone. */ +function sectionEntries(value: unknown, filename: string, section: 'workSources' | 'icons'): { id: string; source: string }[] { if (value === undefined) return [] - if (!Array.isArray(value)) throw new Error(`${filename} workSources must be an array`) - if (value.length > 1) throw new Error(`${filename} supports at most one work source; remove one before adding another`) + if (!Array.isArray(value)) throw new Error(`${filename} ${section} must be an array`) return value.map(entry => { if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some(key => !['id', 'source'].includes(key))) { - throw new Error(`${filename} workSources entries must contain only id and source`) + throw new Error(`${filename} ${section} entries must contain only id and source`) } if (typeof entry.id !== 'string' || !pluginId.test(entry.id)) { - throw new Error(`${filename} workSources id must be lowercase kebab-case`) + throw new Error(`${filename} ${section} id must be lowercase kebab-case`) } if (typeof entry.source !== 'string' || !entry.source.trim()) { - throw new Error(`${filename} workSources source must be non-empty`) + throw new Error(`${filename} ${section} source must be non-empty`) } return { id: entry.id, source: entry.source } }) } +function configuredWorkSources(value: unknown, filename: string): ConfiguredWorkSource[] { + if (Array.isArray(value) && value.length > 1) throw new Error(`${filename} supports at most one work source; remove one before adding another`) + return sectionEntries(value, filename, 'workSources') +} + function configuredScanners(values: unknown[], sourceFilename: string): ConfiguredScanner[] { const scanners = values.map((scanner, index) => { return configuredScanner(scanner, index, sourceFilename) @@ -151,8 +163,8 @@ function parsePluginConfig( throw new Error(`${sourceFilename} must be an object`) } const config = value as Record - if (Object.keys(config).some(key => !['scanners', 'workSources', 'exclude', 'useGitignore'].includes(key)) || !Array.isArray(config.scanners)) { - throw new Error(`${sourceFilename} must contain a scanners array, and optionally a workSources array, an exclude array and useGitignore`) + if (Object.keys(config).some(key => !['scanners', 'workSources', 'icons', 'exclude', 'useGitignore'].includes(key)) || !Array.isArray(config.scanners)) { + throw new Error(`${sourceFilename} must contain a scanners array, and optionally a workSources array, an icons array, an exclude array and useGitignore`) } if (config.exclude !== undefined && !stringArray(config.exclude)) { throw new Error(`${sourceFilename} exclude must be an array of strings`) @@ -161,11 +173,13 @@ function parsePluginConfig( throw new Error(`${sourceFilename} useGitignore must be true or false`) } const workSources = configuredWorkSources(config.workSources, sourceFilename) + const icons: ConfiguredIconPack[] = sectionEntries(config.icons, sourceFilename, 'icons') const scanners = configuredScanners(config.scanners, sourceFilename) - requireUniqueIds([...scanners, ...workSources], sourceFilename) + requireUniqueIds([...scanners, ...workSources, ...icons], sourceFilename) return { scanners, workSources, + icons, ...(config.exclude === undefined ? {} : { exclude: config.exclude as string[] }), ...(config.useGitignore === undefined ? {} : { useGitignore: config.useGitignore }), } @@ -179,7 +193,7 @@ export async function readPluginConfig(repositoryRoot: string): Promise left.id.localeCompare(right.id)), workSources: config.workSources, + icons: config.icons, ...(config.exclude === undefined ? {} : { exclude: config.exclude }), ...(config.useGitignore === undefined ? {} : { useGitignore: config.useGitignore }), }, null, 2)}\n` diff --git a/src/scanner/modules/official-catalog.ts b/src/scanner/modules/official-catalog.ts index d833371a..a8793a32 100644 --- a/src/scanner/modules/official-catalog.ts +++ b/src/scanner/modules/official-catalog.ts @@ -18,6 +18,7 @@ import javascript from '../../../plugins/scanners/javascript/package.json' import scala from '../../../plugins/scanners/scala/package.json' import kotlin from '../../../plugins/scanners/kotlin/package.json' import backlog from '../../../plugins/work-sources/backlog/package.json' +import architectureIcons from '../../../plugins/icons/architecture/package.json' export interface OfficialScanner { id: string @@ -47,3 +48,6 @@ export const officialScannerCatalog: readonly OfficialScanner[] = [ /** The official work sources, decided like the scanners above: by the id their embedded manifest declares. */ export const officialWorkSourceIds: readonly string[] = [backlog].map(manifest => manifest.groma.workSource.id) + +/** The official icon packs, decided the same way. */ +export const officialIconPackIds: readonly string[] = [architectureIcons].map(manifest => manifest.groma.icons.id) diff --git a/src/scanner/modules/package.ts b/src/scanner/modules/package.ts index e0fbac33..f127cc6a 100644 --- a/src/scanner/modules/package.ts +++ b/src/scanner/modules/package.ts @@ -30,7 +30,6 @@ export type PluginSource = NpmPluginSource | LocalPluginSource | GitPluginSource /** What every resolved plugin package has, whatever its kind. */ interface ResolvedPackageFields { - entry: string id: string name: string version: string @@ -38,6 +37,8 @@ interface ResolvedPackageFields { export interface ResolvedScannerPackage extends ResolvedPackageFields { kind: 'scanner' + /** The module groma.md imports to run the scanner. */ + entry: string discovery?: ScannerDiscoveryMetadata /** Git ignore patterns naming the files the scanner reads, written into its configuration when it is added. */ include: string[] @@ -47,14 +48,24 @@ export interface ResolvedScannerPackage extends ResolvedPackageFields { export interface ResolvedWorkSourcePackage extends ResolvedPackageFields { kind: 'workSource' + /** The module groma.md imports to run the adapter. */ + entry: string /** The groma.md versions the adapter's manifest says it works with. */ compatibility?: { groma?: string } } +/** An icon pack holds images and no code, so it has no entry to import. */ +export interface ResolvedIconPackage extends ResolvedPackageFields { + kind: 'icons' + /** Each icon name the pack declares, with the absolute path of its SVG file. */ + icons: Record +} + /** The resolved package of each plugin kind. A caller that asks for one kind gets that kind's package type. */ interface ResolvedPackages { scanner: ResolvedScannerPackage workSource: ResolvedWorkSourcePackage + icons: ResolvedIconPackage } export type PluginKind = keyof ResolvedPackages @@ -134,24 +145,45 @@ function object(value: unknown, label: string): Record { return value as Record } -async function pluginEntry(packageRoot: string, value: unknown): Promise { +/** + * The absolute path of a file a manifest names inside its package, or undefined while that file is not on this + * computer. `label` says in an error which manifest value is wrong. + */ +async function packageFile(packageRoot: string, value: unknown, label: string): Promise { if (typeof value !== 'string' || !value.startsWith('./')) { - throw new Error('plugin package.json entry must be a relative ./path') + throw new Error(`${label} must be a relative ./path`) } - const entry = path.resolve(packageRoot, value) - const relativeEntry = path.relative(packageRoot, entry) - if (relativeEntry.startsWith('..') || path.isAbsolute(relativeEntry)) { - throw new Error('plugin package.json entry must stay inside its package') + const file = path.resolve(packageRoot, value) + const relativeFile = path.relative(packageRoot, file) + if (relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) { + throw new Error(`${label} must stay inside its package`) } try { - if (!(await stat(entry)).isFile()) { - throw new Error(`plugin package entry must be a file: ${value}`) + if (!(await stat(file)).isFile()) { + throw new Error(`${label} must be a file: ${value}`) } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined throw error } - return entry + return file +} + +/** + * Each icon a pack declares, with the absolute path of its SVG file. This is the one check of a pack's files: + * a declared icon whose file is missing or is not an .svg file blocks the whole pack. + */ +async function iconFiles(packageRoot: string, declared: unknown): Promise> { + const files = object(declared, 'plugin package.json groma.icons.icons') + if (Object.keys(files).length === 0) throw new Error('plugin package.json groma.icons.icons must declare at least one icon') + const icons: Record = {} + for (const [name, value] of Object.entries(files)) { + if (!pluginId.test(name)) throw new Error(`icon name must be lowercase kebab-case: ${name}`) + const file = await packageFile(packageRoot, value, `icon ${name}`) + if (!file?.endsWith('.svg')) throw new Error(`icon ${name} must name an existing SVG file`) + icons[name] = file + } + return icons } /** A scanner manifest's include globs, which it must declare, and its optional default exclusions. */ @@ -185,11 +217,33 @@ async function manifestText(packageRoot: string): Promise { } } +/** The fields a manifest may declare under `groma.`. */ +const declaredFields: Record = { + scanner: ['id', 'entry', 'discovery', 'include', 'exclude'], + workSource: ['id', 'entry', 'compatibility'], + icons: ['id', 'icons'], +} + /** The kind a manifest declares, for a caller that accepts any kind. */ function declaredKind(groma: Record): PluginKind { if (groma.scanner !== undefined) return 'scanner' if (groma.workSource !== undefined) return 'workSource' - throw new Error('plugin package.json groma must contain a scanner or a workSource section') + if (groma.icons !== undefined) return 'icons' + throw new Error('plugin package.json groma must contain a scanner, a workSource or an icons section') +} + +/** The id, name and version every kind declares the same way. */ +function packageIdentity(manifest: Record, declared: Record, kind: PluginKind): ResolvedPackageFields { + if (typeof declared.id !== 'string' || !pluginId.test(declared.id)) { + throw new Error(`plugin package.json groma.${kind}.id must be lowercase kebab-case`) + } + if (typeof manifest.name !== 'string' || manifest.name.trim() === '') { + throw new Error('plugin package.json name must be non-empty') + } + if (typeof manifest.version !== 'string' || manifest.version.trim() === '') { + throw new Error('plugin package.json version must be non-empty') + } + return { id: declared.id, name: manifest.name, version: manifest.version } } /** @@ -204,25 +258,18 @@ async function pluginPackage(packageRoot: string, kind?: PluginKind): Promise !allowed.includes(field))) { throw new Error(`plugin package.json groma.${kind} may contain only ${allowed.join(', ')}`) } + const identity = packageIdentity(manifest, declared, kind) + if (kind === 'icons') return { ...identity, kind, icons: await iconFiles(packageRoot, declared.icons) } const details = kind === 'scanner' ? { kind, ...scannerLists(declared) } : { kind, compatibility: workSourceCompatibility(declared.compatibility) } - if (typeof declared.id !== 'string' || !pluginId.test(declared.id)) { - throw new Error(`plugin package.json groma.${kind}.id must be lowercase kebab-case`) - } - if (typeof manifest.name !== 'string' || manifest.name.trim() === '') { - throw new Error('plugin package.json name must be non-empty') - } - if (typeof manifest.version !== 'string' || manifest.version.trim() === '') { - throw new Error('plugin package.json version must be non-empty') - } - const entry = await pluginEntry(packageRoot, declared.entry) + const entry = await packageFile(packageRoot, declared.entry, 'plugin package.json entry') if (entry === undefined) return undefined - const shared = { entry, id: declared.id, name: manifest.name, version: manifest.version } + const shared = { entry, ...identity } if (details.kind === 'workSource') return { ...shared, ...details } return { ...shared, ...details, ...(declared.discovery === undefined ? {} : { discovery: parseScannerDiscovery(declared.discovery) }) } } @@ -322,7 +369,10 @@ async function installGitPackage( await installDependencies(temporary, pinned.source, registry) const destination = installDirectory(cache, pinned.source) await rename(temporary, destination) - return { source: pinned.source, package: { ...resolved, entry: path.join(destination, path.relative(temporary, resolved.entry)) } } + const moved = (file: string) => path.join(destination, path.relative(temporary, file)) + return { source: pinned.source, package: resolved.kind === 'icons' + ? { ...resolved, icons: Object.fromEntries(Object.entries(resolved.icons).map(([name, file]) => [name, moved(file)])) } + : { ...resolved, entry: moved(resolved.entry) } } } finally { await rm(temporary, { recursive: true, force: true }) } diff --git a/src/scanner/modules/published.ts b/src/scanner/modules/published.ts index 39e02990..56d7d88a 100644 --- a/src/scanner/modules/published.ts +++ b/src/scanner/modules/published.ts @@ -6,7 +6,7 @@ export interface PublishedPlugin { version: string os?: string[] cpu?: string[] - groma?: { workSource?: { id?: string; entry?: string; compatibility?: { groma?: string } }; scanner?: { id?: string; entry?: string; discovery?: { compatibility?: { groma?: string } } } } + groma?: { icons?: { id?: string; icons?: Record }; workSource?: { id?: string; entry?: string; compatibility?: { groma?: string } }; scanner?: { id?: string; entry?: string; discovery?: { compatibility?: { groma?: string } } } } } export function isNpmPackageName(value: string): boolean { @@ -19,12 +19,17 @@ function supports(values: string[] | undefined, current: string): boolean { && (values.includes(current) || values.includes('any') || values.every(value => value.startsWith('!'))) } -/** What a release declares for the wanted kind, or for the kind it is when any will do: its id, entry and required groma.md range. */ -function declaration(item: PublishedPlugin, kind?: PluginKind): { id?: string; entry?: string; requires?: string } | undefined { - const scanner = kind === 'workSource' ? undefined : item.groma?.scanner - if (scanner) return { id: scanner.id, entry: scanner.entry, requires: scanner.discovery?.compatibility?.groma } - const workSource = kind === 'scanner' ? undefined : item.groma?.workSource - return workSource && { id: workSource.id, entry: workSource.entry, requires: workSource.compatibility?.groma } +/** + * What a release declares for the wanted kind, or for the kind it is when any will do: its id, whether it has what + * makes it usable (an entry to run, or icons to draw) and its required groma.md range. + */ +function declaration(item: PublishedPlugin, kind?: PluginKind): { id?: string; usable: boolean; requires?: string } | undefined { + const wanted = (candidate: PluginKind) => kind === undefined || kind === candidate + const { scanner, workSource, icons } = item.groma ?? {} + if (scanner && wanted('scanner')) return { id: scanner.id, usable: Boolean(scanner.entry), requires: scanner.discovery?.compatibility?.groma } + if (workSource && wanted('workSource')) return { id: workSource.id, usable: Boolean(workSource.entry), requires: workSource.compatibility?.groma } + if (icons && wanted('icons')) return { id: icons.id, usable: icons.icons !== undefined } + return undefined } /** @@ -40,7 +45,7 @@ export function selectPublishedPlugin( const requirement = (item: PublishedPlugin) => declaration(item, kind)?.requires const releases = Object.values(versions).filter(item => { const declared = declaration(item, kind) - return item.name === name && declared?.id && declared.entry && Bun.semver.satisfies(item.version, '*') + return item.name === name && declared?.id && declared.usable && Bun.semver.satisfies(item.version, '*') }).sort((a, b) => Bun.semver.order(b.version, a.version)) if (!releases.length) throw new Error(`${name}: no published stable release. Ask the plugin author to publish one, then retry.`) const platform = releases.filter(item => supports(item.os, host.os) && supports(item.cpu, host.cpu)) diff --git a/src/scanner/modules/settings-model.ts b/src/scanner/modules/settings-model.ts index 84ba0a27..87912701 100644 --- a/src/scanner/modules/settings-model.ts +++ b/src/scanner/modules/settings-model.ts @@ -20,8 +20,11 @@ export interface ScannerSetting extends PluginSettingFields { export interface WorkSourceSetting extends PluginSettingFields { kind: 'workSource' } +export interface IconPackSetting extends PluginSettingFields { + kind: 'icons' +} /** One row of the plugin settings list; `kind` tells which fields the row has. */ -export type PluginSetting = ScannerSetting | WorkSourceSetting +export type PluginSetting = ScannerSetting | WorkSourceSetting | IconPackSetting export interface PluginSettings { plugins: PluginSetting[] notice: { tone: 'neutral' | 'hint' | 'warning' | 'error'; message: string } @@ -55,7 +58,7 @@ export function scannerNotice(scanners: readonly ScannerSetting[], limits: reado export function pluginSettingLabel(plugin: PluginSetting): string { if (plugin.status === 'blocked') return plugin.message - if (plugin.kind === 'workSource') return plugin.message + if (plugin.kind !== 'scanner') return plugin.message if (plugin.match === 'none' && plugin.status !== 'missing') return 'No matching project files' return { ready: '', unchecked: '', missing: 'Package missing', available: 'Not installed' }[plugin.status] } @@ -70,6 +73,7 @@ export function pluginGroups(plugins: readonly PluginSetting[], query = ''): { t { title: 'Scanners · Installed', plugins: scanners.filter(scanner => scanner.source && scanner.status !== 'missing') }, { title: 'Scanners · Missing on this computer', plugins: scanners.filter(scanner => scanner.source && scanner.status === 'missing') }, { title: 'Scanners · Recommended', plugins: scanners.filter(scanner => !scanner.source) }, + { title: 'Icon packs', plugins: matching.filter(plugin => plugin.kind === 'icons') }, { title: 'Work sources', plugins: matching.filter(plugin => plugin.kind === 'workSource') }, ].filter(group => group.plugins.length) } diff --git a/src/scanner/modules/settings.ts b/src/scanner/modules/settings.ts index 9296d86b..ac0334ea 100644 --- a/src/scanner/modules/settings.ts +++ b/src/scanner/modules/settings.ts @@ -1,14 +1,15 @@ -import { scannerNotice, type ScannerSetting, type PluginSettings, type PluginSettingsAction, type WorkSourceSetting } from './settings-model.ts' +import { scannerNotice, type IconPackSetting, type ScannerSetting, type PluginSettings, type PluginSettingsAction, type WorkSourceSetting } from './settings-model.ts' export * from './settings-model.ts' import type { ScannerDiscovery } from './discovery.ts' import { discoverScanners } from './discovery.ts' import { officialScannerCatalog } from './catalog.ts' -import { officialWorkSourceIds } from './official-catalog.ts' +import { officialIconPackIds, officialWorkSourceIds } from './official-catalog.ts' import { configuredScannerModules, addScanner } from './inventory.ts' import type { ScannerModuleLocation } from './inventory.ts' import type { ProjectReadiness } from './readiness.ts' import { parsePluginSource, type PluginInstallOptions } from './package.ts' import { readPublishedPlugins, selectPublishedPlugin } from './published.ts' +import { iconPackInventory, type IconPackSelection } from '../../icon-packs.ts' import { addPlugin, removePlugin, restorePlugin, updatePlugin } from '../../plugin-management.ts' import { checkWorkSourceReadiness, type WorkSourceSelection } from '../../work-sources.ts' @@ -96,12 +97,22 @@ function workSourceSetting(work: WorkSourceSelection): WorkSourceSetting { } } +function iconPackSetting(selected: IconPackSelection): IconPackSetting { + const pack = selected.status === 'found' ? selected.pack : undefined + return { + kind: 'icons', id: selected.id, name: pack?.name ?? selected.id, source: selected.source, version: pack?.version, + official: officialIconPackIds.includes(selected.id), + status: selected.status === 'found' ? 'ready' : selected.status, message: selected.message, + } +} + export async function readPluginSettings(root: string, checks: readonly ProjectReadiness[] = [], options: PluginInstallOptions = {}): Promise { try { const [proposal, modules] = await Promise.all([discoverScanners(root, options), configuredScannerModules(root, options)]) const state = scannerSettingsState(proposal, modules, checks) const work = await checkWorkSourceReadiness(root, options) - return work ? { ...state, plugins: [...state.plugins, workSourceSetting(work)] } : state + const icons = (await iconPackInventory(root, options)).map(iconPackSetting) + return { ...state, plugins: [...state.plugins, ...(work ? [workSourceSetting(work)] : []), ...icons] } } catch (error) { return { plugins: [], notice: { tone: 'error', message: error instanceof Error ? error.message : String(error) }, limits: [] } } diff --git a/src/sheet/measure.ts b/src/sheet/measure.ts index 8ceffbbe..88c0e3a2 100644 --- a/src/sheet/measure.ts +++ b/src/sheet/measure.ts @@ -18,6 +18,8 @@ export const ISLAND_FONT = 48 export const ISLAND_SPACING = 0.14 export const CONTAINER_FONT = 36 export const GROUP_FONT = 28 +/** Width an element's icon takes before a surface name, in font sizes: the icon's square and the gap after it. */ +export const ICON_ADVANCE = 1.2 /** A roof line wider than this wraps, when the name has a space to wrap at. */ const MAX_LINE_CELLS = 4 @@ -70,9 +72,9 @@ export function labelBand(size: number): number { return Math.ceil(labelHeight(size * 3) / PLANE) } -/** Cells a surface needs along +gx so its own name fits in its front band. */ -export function nameCells(name: string, size: number, spacing = 0): number { - return Math.ceil((textWidth(name, size, spacing) + 2 * SURFACE_PAD) / PLANE) +/** Cells a surface needs along +gx so its own name, after its icon when it has one, fits in its front band. */ +export function nameCells(name: string, size: number, spacing = 0, icon = false): number { + return Math.ceil((textWidth(name, size, spacing) + (icon ? size * ICON_ADVANCE : 0) + 2 * SURFACE_PAD) / PLANE) } /** The name as roof lines: one line, or two split at the space nearest the middle when one line would exceed four cells. */ @@ -222,22 +224,26 @@ export function areaUnitsOf( /** An actor's round building or an external system's pill: one curved tier. */ export const curved = (shape: Shape): boolean => shape.kind === 'round' || shape.kind === 'pill' -/** The name's block on a roof in plane pixels: the longest line with ROOF_PAD around it, one ROOF_LINE_HEIGHT per line. */ -export function roofBlock(lines: readonly string[], size = ROOF_FONT): { w: number; d: number } { +/** + * The name's block on a roof in plane pixels: the longest line with ROOF_PAD around it and one ROOF_LINE_HEIGHT per + * line. A building with an icon has one more line, above the name, that holds the icon's square. + */ +export function roofBlock(lines: readonly string[], size = ROOF_FONT, icon = false): { w: number; d: number } { return { - w: Math.max(...lines.map(line => textWidth(line, size))) + 2 * textPadding(size), - d: 2 * textPadding(size) + lines.length * textLineHeight(size), + w: Math.max(...lines.map(line => textWidth(line, size)), icon ? size : 0) + 2 * textPadding(size), + d: 2 * textPadding(size) + (lines.length + (icon ? 1 : 0)) * textLineHeight(size), } } -/** The roof holds its name and every wall has room for the building's connection ports. */ +/** The roof holds its name, with its icon when it has one, and every wall has room for the building's connection ports. */ export function footprintOf( lines: readonly string[], shape: Shape, degree: number, size = ROOF_FONT, + icon = false, ): { w: number; d: number } { - const block = roofBlock(lines, size) + const block = roofBlock(lines, size, icon) const portSide = Math.ceil(portSideCells(degree)) if (shape.kind === 'round') { const side = Math.max(MIN_SIDE, portSide, Math.ceil(Math.hypot(block.w, block.d) / PLANE)) diff --git a/src/sheet/place.ts b/src/sheet/place.ts index 9f8f9823..6b22648e 100644 --- a/src/sheet/place.ts +++ b/src/sheet/place.ts @@ -66,6 +66,8 @@ function item(element: AnnotatedElement): SheetItem { id: element.id, title: element.title, origin: element.origin, + resolvedIcon: element.resolvedIcon, + colour: element.colour, } } @@ -75,7 +77,7 @@ function buildingNode(element: AnnotatedElement, ranges: FileMeasureRanges, degr : element.external ? { kind: 'pill' } : { kind: 'block' } const size = buildingFont(element) const lines = shape.kind === 'pill' ? [element.title] : roofLines(element.title, size) - const base = footprintOf(lines, shape, degree, size) + const base = footprintOf(lines, shape, degree, size, element.resolvedIcon !== undefined) const floors = element.kind === 'component' ? floorsOf(element.origin, element.code, ranges, base) : [] @@ -104,9 +106,9 @@ function subtreeConnections(key: string, children: readonly Node[], relationship /** The cells a surface's own name needs in its front band. */ function nameWidth(paint: Node['paint']): number { - if (paint.kind === 'island') return nameCells(paint.name.toUpperCase(), ISLAND_FONT, ISLAND_SPACING) + if (paint.kind === 'island') return nameCells(paint.name.toUpperCase(), ISLAND_FONT, ISLAND_SPACING, paint.element?.resolvedIcon !== undefined) if (paint.kind === 'zone') return nameCells(paint.name, GROUP_FONT) - if (paint.kind === 'slab') return nameCells(paint.element.title, CONTAINER_FONT) + if (paint.kind === 'slab') return nameCells(paint.element.title, CONTAINER_FONT, 0, paint.element.resolvedIcon !== undefined) return 0 } diff --git a/src/sheet/types.ts b/src/sheet/types.ts index f044fa7b..39b85256 100644 --- a/src/sheet/types.ts +++ b/src/sheet/types.ts @@ -1,4 +1,4 @@ -import type { AnnotatedRelationship, C4Kind, Origin } from '../types.ts' +import type { AnnotatedRelationship, C4Kind, Origin, ResolvedIcon } from '../types.ts' /** A rectangle in map cells: `gx`, `gy` is the north corner, `w` runs along gx, `d` along gy. */ export interface CellRect { @@ -14,6 +14,8 @@ export interface SheetItem { id: string title: string origin: Origin + resolvedIcon?: ResolvedIcon + colour?: string } export type IslandKind = 'actors' | 'external' | 'system' diff --git a/src/types.ts b/src/types.ts index 631a4089..1599c1e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,8 @@ export interface ArchitectureElement { external: boolean group?: string technology?: string + icon?: string + colour?: string code: CodeReference[] status: ElementStatus /** The draft record this element belongs to; a stable element may carry it too. */ @@ -130,6 +132,9 @@ export interface ArchitectureModel { relationships: ArchitectureRelationship[] } +/** How a viewer draws an element's icon: an installed pack's SVG as a data URI, or an emoji as text. */ +export type ResolvedIcon = { kind: 'image'; uri: string } | { kind: 'emoji'; text: string } + export interface AnnotatedElement { representationId: string id: string @@ -142,6 +147,10 @@ export interface AnnotatedElement { external: boolean group?: string technology?: string + icon?: string + colour?: string + /** `icon` as the icon loader resolved it; absent for an unknown name, and never stored in Markdown. */ + resolvedIcon?: ResolvedIcon code: CodeReference[] /** Total lines across the code files; absent only in hand-built worlds. */ codeLines?: number diff --git a/src/viewers/tui/panes/details.ts b/src/viewers/tui/panes/details.ts index d9157791..e4928cfa 100644 --- a/src/viewers/tui/panes/details.ts +++ b/src/viewers/tui/panes/details.ts @@ -220,14 +220,16 @@ export function detailsLines( dim(theme, ' · '), chunk(element.origin, theme[element.origin], TextAttributes.BOLD), ] + const appearance = [element.icon ? `Icon: ${element.icon}` : '', element.colour ? `Colour: ${element.colour}` : ''] + .filter(Boolean).flatMap(value => wrap(value, width).map(row => [plain(theme, row)])) const body = tab === 'tasks' ? componentTaskLines(theme, element, world, width, actionCursor, workList) : tab === 'how' ? howLines(theme, element, world, width, actionCursor, structure) : whatLines(theme, element, world, byId, width, pickedId, actionCursor) return { - lines: [head, ...body.lines], - ...(body.cursor === undefined ? {} : { cursor: body.cursor + 1 }), + lines: [head, ...appearance, ...body.lines], + ...(body.cursor === undefined ? {} : { cursor: body.cursor + 1 + appearance.length }), } } diff --git a/src/viewers/web/chrome/colour-legend.ts b/src/viewers/web/chrome/colour-legend.ts new file mode 100644 index 00000000..22fd59fb --- /dev/null +++ b/src/viewers/web/chrome/colour-legend.ts @@ -0,0 +1,37 @@ +import { accentColour, accentColours } from '../../../element-appearance.ts' +import type { AnnotatedElement } from '../../../types.ts' + +export const colourLegendCss = ` + #colour-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 8px; } + #colour-legend:empty { display: none; } + #colour-legend i { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--element-colour); } +` + +/** + * The accent colours in use, each once however its elements spell it: a name and its hex are one colour. + * `colour` is the lower-case hex; `label` is the colour's name, or the hex again when it has none. + */ +export function colourCounts( + elements: readonly Pick[], +): { colour: string; label: string; count: number }[] { + const counts = new Map() + for (const element of elements) { + const colour = accentColour(element.colour) + if (colour !== undefined) counts.set(colour, (counts.get(colour) ?? 0) + 1) + } + const nameOf = (colour: string) => Object.keys(accentColours).find(name => accentColours[name] === colour) + return [...counts] + .map(([colour, count]) => ({ colour, label: nameOf(colour) ?? colour, count })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +/** Fills the page's colour legend; with no accent colour in use it stays empty, and the stylesheet hides it. */ +export function paintColourLegend(host: HTMLElement, elements: readonly AnnotatedElement[]): void { + host.replaceChildren(...colourCounts(elements).map(({ colour, label, count }) => { + const row = document.createElement('span') + const swatch = document.createElement('i') + swatch.style.setProperty('--element-colour', colour) + row.append(swatch, `${label} ${count}`) + return row + })) +} diff --git a/src/viewers/web/comparison/details.ts b/src/viewers/web/comparison/details.ts index 1d9c9597..edb6bfb0 100644 --- a/src/viewers/web/comparison/details.ts +++ b/src/viewers/web/comparison/details.ts @@ -63,7 +63,8 @@ function changedText(className: string, before: string | undefined, after: strin const fields = [ ['title', 'name', 'what'], ['technology', 'technology', 'how'], ['parent', 'parent', 'what'], - ['group', 'group', 'what'], ['origin', 'status', 'what'], ['draft', 'draft', 'what'], + ['group', 'group', 'what'], ['icon', 'icon', 'what'], ['colour', 'colour', 'what'], + ['origin', 'status', 'what'], ['draft', 'draft', 'what'], ] as const export function componentReasons(change: ComponentChange | undefined) { @@ -134,6 +135,8 @@ export function comparisonOverview(body: Element, change: ComponentChange, world changedField(body, 'Name', before.title, after.title) changedField(body, 'Parent', parentName(before.parent), parentName(after.parent)) changedField(body, 'Group', before.group, after.group) + changedField(body, 'Icon', before.icon, after.icon) + changedField(body, 'Colour', before.colour, after.colour) changedField(body, 'Status', before.origin, after.origin) changedField(body, 'Draft', before.draft, after.draft) } diff --git a/src/viewers/web/iso/painting/appearance.ts b/src/viewers/web/iso/painting/appearance.ts new file mode 100644 index 00000000..a736f0ab --- /dev/null +++ b/src/viewers/web/iso/painting/appearance.ts @@ -0,0 +1,39 @@ +import { accentColour } from '../../../../element-appearance.ts' +import type { SheetItem } from '../../../../sheet/types.ts' +import type { ResolvedIcon } from '../../../../types.ts' +import type { ProjectedScene } from '../projection/project.ts' +import { node, type SvgNode } from './svg.ts' + +/** + * Gives an element's group its authored accent. The group only carries the colour; the accent rules in style.ts + * paint the element's own faces, ground and title from it. The colour travels as the `color` attribute and not as + * a custom property, because the cover renderer cannot read `var()`: the browser's rules take it as `currentColor` + * and a cover's rules select the group by it. Those rules follow the rest colours and precede the draft, selection + * and comparison rules, so every state still wins, in the browser and in covers alike. + */ +export function withAccent(group: SvgNode, item: Pick): SvgNode { + const colour = accentColour(item.colour) + if (colour === undefined) return group + return { ...group, attributes: { ...group.attributes, color: colour, class: `${group.attributes.class} accented` } } +} + +/** The distinct accents among a scene's elements; a cover's stylesheet needs a rule for each. */ +export function sceneAccents(scene: ProjectedScene): string[] { + const items = [ + ...scene.islands.map(item => item.island.element), + ...scene.slabs.map(item => item.slab), + ...scene.buildings.map(item => item.building), + ] + return [...new Set(items.flatMap(item => accentColour(item?.colour) ?? []))] +} + +/** + * An element's icon in the square at `x`, `y`. A pack icon is an image. An emoji is text in the reader's own + * emoji font, centred in the square because that font decides how wide the glyph is. + */ +export function iconNode(icon: ResolvedIcon, x: number, y: number, size: number): SvgNode { + if (icon.kind === 'image') { + return node('image', { href: icon.uri, 'xlink:href': icon.uri, x, y, width: size, height: size, 'pointer-events': 'none' }, 'element-icon') + } + return node('text', { x: x + size / 2, y: y + size * 0.9, 'font-size': size, 'text-anchor': 'middle' }, 'text emoji-icon', icon.text) +} diff --git a/src/viewers/web/iso/painting/buildings.ts b/src/viewers/web/iso/painting/buildings.ts index c0404c3c..07a2ca42 100644 --- a/src/viewers/web/iso/painting/buildings.ts +++ b/src/viewers/web/iso/painting/buildings.ts @@ -1,6 +1,7 @@ import { buildingFont } from '../../../../sheet/measure.ts' import type { BuildingFloor } from '../../../../sheet/types.ts' import type { Plane, ProjectedBuilding, ProjectedScene } from '../projection/project.ts' +import { withAccent } from './appearance.ts' import { facadePattern, facadePatternId } from './style.ts' import { node, pointsAttribute, type SvgNode } from './svg.ts' import { surfaceText } from './text.ts' @@ -49,9 +50,9 @@ function floorSvg( export function buildingsSvg(scene: ProjectedScene): SvgNode[] { return scene.buildings.map(projected => { const { building, floors, text } = projected - return node('g', { 'aria-label': building.title, 'data-id': building.representationId }, classOf(projected), [ + return withAccent(node('g', { 'aria-label': building.title, 'data-id': building.representationId }, classOf(projected), [ ...floors.flatMap((faces, index) => floorSvg(projected, faces, building.floors[index])), - surfaceText(text, buildingFont(building), 'label', scene.view), - ]) + surfaceText(text, buildingFont(building), 'label', scene.view, building.resolvedIcon), + ]), building) }) } diff --git a/src/viewers/web/iso/painting/ground.ts b/src/viewers/web/iso/painting/ground.ts index 3bc2c844..3f55f768 100644 --- a/src/viewers/web/iso/painting/ground.ts +++ b/src/viewers/web/iso/painting/ground.ts @@ -2,6 +2,7 @@ import { CONTAINER_FONT, GROUP_FONT, ISLAND_FONT, ISLAND_SPACING, textWidth } fr import type { Compass, PlateText, ProjectPlate, RichPlateText, Segment } from '../projection/blueprint.ts' import type { ProjectedScene, ProjectedZone, ProjectionView } from '../projection/project.ts' import { planeMatrix } from '../projection/project.ts' +import { withAccent } from './appearance.ts' import { node, pointsAttribute, round, type SvgNode } from './svg.ts' import { surfaceLabel } from './text.ts' @@ -99,11 +100,11 @@ export function islandsSvg(scene: ProjectedScene, zoom = 1): SvgNode[] { ...scene.islands.map(({ island, polygon, text }) => { const attributes: Record = island.element === null ? {} : { 'data-id': island.element.representationId, 'aria-label': island.name } - return node('g', attributes, `island ${island.kind}`, [ + return withAccent(node('g', attributes, `island ${island.kind}`, [ node('polygon', { points: pointsAttribute(polygon) }, 'ground'), ...(island.kind === 'system' ? [] : [node('polygon', { points: pointsAttribute(polygon) }, 'pattern')]), - surfaceLabel(text, ISLAND_FONT, scene.view, ISLAND_SPACING, zoom), - ]) + surfaceLabel(text, ISLAND_FONT, scene.view, ISLAND_SPACING, zoom, island.element?.resolvedIcon), + ]), island.element ?? {}) }), ...scene.zones.filter(zone => scene.islands.some(item => item.island.key === zone.zone.parent)) .map(zone => zoneGroup(zone, scene.view, zoom)), @@ -115,12 +116,12 @@ export function slabsSvg(scene: ProjectedScene, zoom = 1): SvgNode[] { return scene.slabs.map(({ slab, faces, text }) => { const ghost = slab.origin === 'observed' ? '' : ` ghost ${slab.origin}` const top = faces.find(face => face.side === 'top')! - return node('g', { 'aria-label': slab.title, 'data-id': slab.representationId }, `slab${ghost}`, [ + return withAccent(node('g', { 'aria-label': slab.title, 'data-id': slab.representationId }, `slab${ghost}`, [ ...faces.map(face => node('polygon', { points: pointsAttribute(face.points) }, `face ${face.side}`)), node('polygon', { points: pointsAttribute(top.points) }, 'pattern'), - surfaceLabel(text, CONTAINER_FONT, scene.view, 0, zoom), + surfaceLabel(text, CONTAINER_FONT, scene.view, 0, zoom, slab.resolvedIcon), ...scene.zones.filter(zone => zone.zone.parent === slab.representationId) .map(zone => zoneGroup(zone, scene.view, zoom)), - ]) + ]), slab) }) } diff --git a/src/viewers/web/iso/painting/scale.ts b/src/viewers/web/iso/painting/scale.ts index 5a685a21..bfb86487 100644 --- a/src/viewers/web/iso/painting/scale.ts +++ b/src/viewers/web/iso/painting/scale.ts @@ -34,6 +34,14 @@ export function tintAt(depth: number): number { return ISLAND_TINT * TINT_RATIO ** depth } +/** Share of an element's authored accent mixed into the paper of its own surfaces, the same in every theme. */ +export const ACCENT_TINT = 0.22 +/** + * Share of ink mixed into an accent for the element's title, the same in every theme, so the title lightens on dark + * paper and darkens on light paper. At this share every named accent reads on its own tint at a contrast of 4.5 or more. + */ +export const ACCENT_TITLE = 0.45 + /** Hover, selection and lit routes climb the stroke ladder by this many levels. */ export function emphasis(steps: number): number { return STROKE_RATIO ** steps diff --git a/src/viewers/web/iso/painting/style.ts b/src/viewers/web/iso/painting/style.ts index fa70a811..3dcd6e5e 100644 --- a/src/viewers/web/iso/painting/style.ts +++ b/src/viewers/web/iso/painting/style.ts @@ -3,7 +3,7 @@ import { layerCss } from './layer-planes.ts' import { glowCss } from './glow.ts' import { DEFAULT_PROJECTION, planeMatrix } from '../projection/project.ts' import type { Plane, ProjectionView } from '../projection/project.ts' -import { FACADE_MARK, SIDE, SURFACE_TILE, depthOf, emphasis, facadeDetailsVisible, strokeAt, surfacePatternsVisible, tintAt } from './scale.ts' +import { ACCENT_TINT, ACCENT_TITLE, FACADE_MARK, SIDE, SURFACE_TILE, depthOf, emphasis, facadeDetailsVisible, strokeAt, surfacePatternsVisible, tintAt } from './scale.ts' import type { Level } from './scale.ts' import { mark, node, type SvgNode } from './svg.ts' @@ -85,13 +85,38 @@ function surfaceColours(kind: string, depth: number, palette?: Palette): string #map .${kind} .left { fill: ${paintTint(tintAt(depth + SIDE.left), palette)}; }` } +/** + * The colours of an accented element. Its group carries the accent as `color` (appearance.ts). The element's own + * faces and ground are stroked in the accent and filled with it mixed into the theme's paper; its title is the accent + * mixed toward the theme's ink. The browser mixes with the live theme through `currentColor`. A cover needs explicit + * colours, so it gets these rules once for each accent in its scene. They have the specificity of the rest colours + * and of the draft rule, and must stay between the two. + */ +function accentRules(palette: Palette | undefined, accents: readonly string[]): string { + const rules = (group: string, accent: string, tint: string, title: string) => + `#map ${group} > .ground, #map ${group} .face { stroke: ${accent}; fill: ${tint}; } + #map ${group} > .label .text { fill: ${title}; }` + const percent = (share: number) => `${(share * 100).toFixed(1)}%` + if (palette === undefined) { + return rules('.accented', 'currentColor', + `color-mix(in srgb, currentColor ${percent(ACCENT_TINT)}, var(--paper))`, + `color-mix(in srgb, var(--ink) ${percent(ACCENT_TITLE)}, currentColor)`) + } + return accents.map(accent => rules(`[color="${accent}"]`, accent, + mixColour(palette.paper, accent, ACCENT_TINT), mixColour(accent, palette.ink, ACCENT_TITLE))).join('\n ') +} + /** The camera attributes that stop facade and surface patterns below readable size at zoom `k`; the map and its covers set the same ones. */ export function patternAttributes(k: number): Record { return { 'data-facades-hidden': !facadeDetailsVisible(k), 'data-surface-patterns-hidden': !surfacePatternsVisible(k) } } -/** Supplying a palette resolves theme values and compensates strokes for a fixed SVG camera. Covers render these rules with resvg, which ignores :is(), so selectors stay plain. */ -export function mapDrawingCss(palette?: Palette, zoom = 1): string { +/** + * Supplying a palette resolves theme values and compensates strokes for a fixed SVG camera. Covers render these rules + * with resvg, which drops a selector that uses :is() or :not() together with every selector listed after it, so + * selectors stay plain. It reads neither var() nor color-mix(), so a cover also names the accents of its scene. + */ +export function mapDrawingCss(palette?: Palette, zoom = 1, accents: readonly string[] = []): string { const paper = palette?.paper ?? 'var(--paper)' const ink = palette?.ink ?? 'var(--ink)' const muted = palette?.muted ?? 'var(--muted)' @@ -150,6 +175,7 @@ export function mapDrawingCss(palette?: Palette, zoom = 1): string { ${surfaceColours('slab', depthOf('slab'), palette)} ${surfaceColours('building', depthOf('building'), palette)} #map .actor .face { fill: ${paper}; } + ${accentRules(palette, accents)} #map .zone .ground { fill: url(#hatch-ground); } #map .pattern { stroke: none; pointer-events: none; } #map .island.actors .pattern { fill: url(#dots); } diff --git a/src/viewers/web/iso/painting/text.ts b/src/viewers/web/iso/painting/text.ts index dbccb309..33815f43 100644 --- a/src/viewers/web/iso/painting/text.ts +++ b/src/viewers/web/iso/painting/text.ts @@ -1,6 +1,8 @@ -import { CONTAINER_FONT, PLANE, textLineHeight, textPadding } from '../../../../sheet/measure.ts' +import { CONTAINER_FONT, ICON_ADVANCE, PLANE, textLineHeight, textPadding } from '../../../../sheet/measure.ts' +import type { ResolvedIcon } from '../../../../types.ts' import type { ProjectionView, SurfaceLabel, SurfaceText } from '../projection/project.ts' import { planeMatrix, project } from '../projection/project.ts' +import { iconNode } from './appearance.ts' import { node, round, type SvgNode } from './svg.ts' /** Fixed plane-space sizes keep titles readable without relaying out at every camera scale. */ @@ -24,6 +26,7 @@ export function surfaceLabelLayout( size: number, zoom: number, view: ProjectionView, + hasIcon = false, ) { const step = TITLE_STEPS[surfaceLabelStep(zoom)]! const length = (x: number, y: number) => { @@ -33,14 +36,18 @@ export function surfaceLabelLayout( } const paddingX = Math.min(step.padding / length(1, 0), text.band.width / 20) const paddingY = Math.min(step.padding / length(0, 1), text.band.height / 20) + const contentWidth = text.width + (hasIcon ? size * ICON_ADVANCE : 0) const fontSize = Math.min( step.size * size / CONTAINER_FONT, - size * (text.band.width - 2 * paddingX) / text.width, + size * (text.band.width - 2 * paddingX) / contentWidth, (text.band.height - 3 * paddingY) / TITLE_LINE_HEIGHT, ) - const width = text.width * fontSize / size + const width = contentWidth * fontSize / size + const iconWidth = hasIcon ? fontSize * ICON_ADVANCE : 0 return { fontSize, + iconWidth, + iconX: (text.width - width) / 2, x: (text.width - width) / 2 - paddingX, width: width + 2 * paddingX, height: 3 * paddingY + fontSize * TITLE_LINE_HEIGHT, @@ -49,20 +56,22 @@ export function surfaceLabelLayout( } } -/** Building names stay inset on their own roofs, laid out in plane pixels. */ +/** Building names stay inset on their own roofs, laid out in plane pixels. An icon takes the first line and the name starts below it. */ export function surfaceText( text: SurfaceText, size: number, className: string, view: ProjectionView, + icon?: ResolvedIcon, ): SvgNode { const padding = textPadding(size) + const firstLine = icon === undefined ? 0 : 1 return node('g', { transform: planeMatrix('ground', text.origin, view) }, className, - text.lines.map((line, index) => node('text', { + [...(icon ? [iconNode(icon, padding, padding, size)] : []), ...text.lines.map((line, index) => node('text', { x: padding, - y: padding + size * 0.9 + index * textLineHeight(size), + y: padding + size * 0.9 + (firstLine + index) * textLineHeight(size), 'font-size': size, - }, 'text', line))) + }, 'text', line))]) } /** The label and its short leader share the surface's identity and ground plane. */ @@ -72,13 +81,15 @@ export function surfaceLabel( view: ProjectionView, spacing = 0, zoom = 1, + icon?: ResolvedIcon, ): SvgNode { - const layout = surfaceLabelLayout(text, size, zoom, view) + const layout = surfaceLabelLayout(text, size, zoom, view, icon !== undefined) return node('g', { transform: planeMatrix('ground', text.origin, view) }, 'label surface-label', [ + ...(icon ? [iconNode(icon, layout.iconX, layout.baseline - layout.fontSize * 0.9, layout.fontSize)] : []), node('rect', { x: layout.x, width: layout.width, height: layout.height }, 'label-hit'), node('line', { x1: text.width / 2, x2: text.width / 2, y1: 0, y2: layout.leader }, 'label-leader'), node('text', { - x: text.width / 2, + x: (text.width + layout.iconWidth) / 2, y: layout.baseline, 'text-anchor': 'middle', 'font-size': layout.fontSize, diff --git a/src/viewers/web/iso/projection/project.ts b/src/viewers/web/iso/projection/project.ts index 9cb23ac2..c9c65537 100644 --- a/src/viewers/web/iso/projection/project.ts +++ b/src/viewers/web/iso/projection/project.ts @@ -333,7 +333,7 @@ function roofText(building: Building, view: ProjectionView): SurfaceText { const top = building.floors.at(-1) const roof = top === undefined ? building.rect : centredRect(building.rect, top.footprint) if (!curved(shape)) return { origin: project(roof.gx, roof.gy, heightUnits, view), lines } - const block = roofBlock(lines, buildingFont(building)) + const block = roofBlock(lines, buildingFont(building), building.resolvedIcon !== undefined) return { origin: project( roof.gx + (roof.w - block.w / PLANE) / 2, diff --git a/src/viewers/web/organisms/details.ts b/src/viewers/web/organisms/details.ts index d17614c1..112e2eef 100644 --- a/src/viewers/web/organisms/details.ts +++ b/src/viewers/web/organisms/details.ts @@ -5,12 +5,14 @@ import type { C4Kind, CodeReference, Origin, + ResolvedIcon, } from '../../../types.ts' import type { Comparison, ComponentChange } from '../../../history/comparison.ts' import { changeBadge, comparisonReasons, comparisonOverview, comparisonTechnology, comparisonFiles } from '../comparison/details.ts' import type { ElementWorkGroup } from '../../../work/pins.ts' import type { Zone } from '../../../sheet/types.ts' import { findingsForOwner } from '../../../architecture-findings.ts' +import { accentColour } from '../../../element-appearance.ts' import { removalBlocker } from '../../../removable.ts' import { flowsThrough } from '../../flows.ts' import type { FlowRef } from '../../flows.ts' @@ -52,6 +54,9 @@ export interface Inspected { flows: FlowRowData[] children: InspectedChild[] technology: string + icon?: string + colour?: string + resolvedIcon?: ResolvedIcon files: CodeReference[] findings: ArchitectureFinding[] /** True when groma remove would succeed on it right now. */ @@ -158,6 +163,9 @@ export function inspectDetails( })), children, technology: element.technology ?? '', + icon: element.icon, + colour: element.colour, + resolvedIcon: element.resolvedIcon, files: element.code, findings: findingsForOwner(world.findings ?? [], element.id), removable: removalBlocker(world, element.id) === undefined, @@ -270,6 +278,32 @@ function paintComparedRelationships(list: HTMLElement, pair: RelationshipPairDat } } +export const elementTitleCss = ` + #details h1 .element-name { color: var(--element-colour, inherit); } + #details h1 .element-icon { width: 24px; height: 24px; margin-right: 8px; vertical-align: middle; } +` + +/** + * The element's name in its accent colour, after its pack icon. The name sits in its own span, so the colour + * leaves with it when another kind of selection writes the heading. An emoji icon is not repeated here: the + * meta line above already shows it as text. + */ +function paintElementTitle(title: HTMLElement, inspected: Inspected): void { + const name = document.createElement('span') + name.className = 'element-name' + name.textContent = inspected.title + const colour = accentColour(inspected.colour) + if (colour !== undefined) name.style.setProperty('--element-colour', colour) + if (inspected.resolvedIcon?.kind === 'image') { + const icon = document.createElement('img') + icon.className = 'element-icon' + icon.src = inspected.resolvedIcon.uri + icon.alt = inspected.icon ?? '' + name.prepend(icon) + } + title.replaceChildren(name) +} + export function paintDetails(host: HTMLElement, inspected: Inspected, options: DetailsOptions): void { const { onSelect, onToggleFlow, activeFlows, tab, onTab, code, onSource, workGroups, onTask, onEdit, selection } = options if (onEdit !== undefined && isEditing(host, inspected.id)) return @@ -277,9 +311,8 @@ export function paintDetails(host: HTMLElement, inspected: Inspected, options: D const meta = host.querySelector('.meta')! const tabsHost = host.querySelector('.tabs')! const body = host.querySelector('.body')! - title.replaceChildren() - title.textContent = inspected.title - meta.textContent = `${inspected.kindLabel} · ${inspected.origin}` + meta.textContent = [inspected.kindLabel, inspected.origin, inspected.icon, inspected.colour].filter(Boolean).join(' · ') + paintElementTitle(title, inspected) const change = options.comparison?.components[inspected.id] if (change !== undefined) { @@ -381,6 +414,8 @@ function elementFields(inspected: Inspected, options: PaneWrites): EditField[] { { name: 'description', label: 'Description', value: inspected.description }, { name: 'overview', label: 'Overview', value: inspected.overview, multiline: true }, { name: 'technology', label: 'Technology', value: inspected.technology }, + { name: 'icon', label: 'Icon', value: inspected.icon ?? '' }, + { name: 'colour', label: 'Colour', value: inspected.colour ?? '' }, ] if (inspected.movable) fields.push({ name: 'parent', label: 'Parent', value: inspected.parent ?? '', options: options.parents ?? [], diff --git a/src/viewers/web/organisms/writes.ts b/src/viewers/web/organisms/writes.ts index 295ac2aa..55faf559 100644 --- a/src/viewers/web/organisms/writes.ts +++ b/src/viewers/web/organisms/writes.ts @@ -4,7 +4,7 @@ import type { EditValues } from '../../../authoring-conflict.ts' import { message } from './editable.ts' /** What the edit verb changes from the pane; the id is the selected element's. */ -export type MeaningEdit = Pick +export type MeaningEdit = Pick export interface ParentOption { id: string diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index eb385ed3..43dd2fb8 100644 --- a/src/viewers/web/page.ts +++ b/src/viewers/web/page.ts @@ -17,6 +17,7 @@ import { anchoredPopoverCss } from './atoms/popover.ts' import { kindGlyph, kindLabel } from '../atoms/kind.ts' import { cssBlock, palettes, themeLabel, themeModes, webFontFamily, type WebThemeMode } from './atoms/theme.ts' import { addDialogCss } from './chrome/add.ts' +import { colourLegendCss } from './chrome/colour-legend.ts' import { creditsControl, creditsCss } from './chrome/credits.ts' import { editorCss } from './editing/gestures.ts' import { emptyState, emptyStateCss } from './chrome/empty.ts' @@ -27,6 +28,7 @@ import { motionCss } from './chrome/motion.ts' import { detailsPanelCss } from './chrome/shell.ts' import { flowRowCss } from './flow/row.ts' import { mapCss } from './iso/painting/style.ts' +import { elementTitleCss } from './organisms/details.ts' import { editableCss } from './organisms/editable.ts' import { removeCss } from './organisms/remove.ts' import { relationshipCardCss } from './organisms/relationship-card.ts' @@ -385,7 +387,7 @@ const style = ` ${floatingBarCss}${chromeCss}${anchoredPopoverCss}${creditsCss}${motionCss}${revisionCss}${searchCss}${highlightCss}${sourceCss}${fileDiffCss}${taskDiffCss}${comparisonDetailsCss}${hierarchyComparisonCss}${comparisonControlCss}${backlogMarkCss}${workBadgeCss}${workDetailsCss}${flowRowCss}${mapCss}${pinsCss}${workCss}${tipCss}${projectEditorCss} ${emptyStateCss} ${addDialogCss}${editorCss} -${relationshipCardCss}${removeCss}${editableCss}${mapDebugCss}${detailsPanelCss}${mapViewCss}${c4FilterCss}` +${relationshipCardCss}${removeCss}${editableCss}${mapDebugCss}${detailsPanelCss}${mapViewCss}${c4FilterCss}${colourLegendCss}${elementTitleCss}` function legend(): string { return legendKinds.map(line => { @@ -461,7 +463,7 @@ export function renderPage(payload: WebBootPayload, url?: URL): string { + searchControl({ search: searchIcon, close: closeIcon }) + `
${projectReviewControl}
${settingsControl(themeControl())}${helpControl()}${creditsControl(infoIcon, lockup)}
` + '' - + `` + + `` + `
${mapViewControl()}${c4FilterControl()}` + emptyState(payload) + `
` diff --git a/src/viewers/web/render.ts b/src/viewers/web/render.ts index 79849cff..a39be2f3 100644 --- a/src/viewers/web/render.ts +++ b/src/viewers/web/render.ts @@ -11,6 +11,7 @@ import { createEmptyState } from './chrome/empty.ts' import { createMapDebugPanel } from './chrome/map-debug.ts' import { bindMapView } from './chrome/map-view.ts' import { bindC4Filter } from './chrome/c4-filter.ts' +import { paintColourLegend } from './chrome/colour-legend.ts' import { animateControl, animateContent } from './chrome/motion.ts' import { measureFrame, type MapFrame } from './chrome/frame.ts' import { bindChromeActions, createWebShell, pageHosts } from './chrome/shell.ts' @@ -460,6 +461,7 @@ function paintWorld(): void { authoring.refresh() pins.paint(currentPins.filter(pin => map.anchorOf(pin.elementId) !== undefined)) island.paint(currentPins, work) + paintColourLegend(document.getElementById('colour-legend')!, world.elements) camera.paint() paintViewState() source.restore() diff --git a/src/viewers/web/scanners/settings.ts b/src/viewers/web/scanners/settings.ts index 98fec501..40a9b34c 100644 --- a/src/viewers/web/scanners/settings.ts +++ b/src/viewers/web/scanners/settings.ts @@ -71,7 +71,7 @@ function detectionDetails(scanner: ScannerSetting): string { } function detailLabel(plugin: PluginSetting): string { - if (plugin.kind === 'workSource') return 'Plugin details' + if (plugin.kind !== 'scanner') return 'Plugin details' if (plugin.status === 'blocked' || plugin.status === 'missing' || !plugin.matches.length) return 'Scanner details' const count = plugin.matches.length return `${count} matching ${count === 1 ? 'file' : 'files'}` @@ -83,7 +83,7 @@ function settingRow(plugin: PluginSetting, upgrades: PluginSettings['upgrades']) const attention = plugin.status === 'blocked' || plugin.status === 'missing' const status = { blocked: 'Needs attention', missing: 'Needs attention', ready: 'Installed', unchecked: 'Installed', available: '' }[plugin.status] const badge = status ? `${status}` : '' - const reason = plugin.kind === 'workSource' || plugin.match === 'matched' ? '' : `

${escaped(scannerMatchReason(plugin))}

` + const reason = plugin.kind !== 'scanner' || plugin.match === 'matched' ? '' : `

${escaped(scannerMatchReason(plugin))}

` const version = [plugin.version, upgrade?.version].filter(Boolean).join(' → ') const metadata = [version, plugin.official ? 'Official' : 'Third-party'].filter(Boolean).join(' · ') const updateError = upgrade?.error ? `

Could not check for updates. ${escaped(upgrade.error)}

` : '' diff --git a/src/viewers/web/sharing/cover.ts b/src/viewers/web/sharing/cover.ts index e66acd5f..e501ec0a 100644 --- a/src/viewers/web/sharing/cover.ts +++ b/src/viewers/web/sharing/cover.ts @@ -4,6 +4,7 @@ import { palettes, webFontFamily, type WebTheme } from '../atoms/theme.ts' import { textWidth } from '../../../sheet/measure.ts' import { fitCamera, pan } from '../iso/camera/camera.ts' import { gridPattern } from '../iso/grid.ts' +import { sceneAccents } from '../iso/painting/appearance.ts' import { buildingsSvg, facadeDefs } from '../iso/painting/buildings.ts' import { islandsSvg, sheetSvg, slabsSvg } from '../iso/painting/ground.ts' import { routesSvg } from '../iso/painting/routes.ts' @@ -23,7 +24,12 @@ function titleWithin(title: string, width: number): string { return `${letters.join('')}…` } -/** The real map sits on one continuous field; a glass footer carries the project's sharing identity. */ +/** + * The real map sits on one continuous field; a glass footer carries the project's sharing identity. The cover + * renderer has only the bundled fonts, which hold no emoji, so an emoji icon is hidden rather than drawn as an empty + * box. The sheet has already reserved its room, which stays empty: the line above a building's name, and the icon's + * width before a surface's name. + */ export function renderCover(payload: CoverPayload, theme: WebTheme): string { const palette = palettes[theme] const scene = presentScene(payload.sheet, payload.project ?? undefined, NESTED_POSE) @@ -41,7 +47,7 @@ export function renderCover(payload: CoverPayload, theme: WebTheme): string { return ` - + ${definitions} ${markup([gridPattern({ ...camera, k: 0.5 }, scene.view)])} diff --git a/src/write-commands.ts b/src/write-commands.ts index 6cf5ba80..ed35a191 100644 --- a/src/write-commands.ts +++ b/src/write-commands.ts @@ -137,6 +137,8 @@ export function registerWriteCommands(program: Command): void { .option('--steps ', 'flow Steps table: From | To | Action, with Markdown endpoint links') .option('--description ', 'optional short summary (empty removes it), or how a relation works') .option('--technology ', 'technology of an element (empty removes it) or of a relation') + .option('--icon ', 'icon from an installed pack, or emoji (empty removes it)') + .option('--colour ', 'accent colour name or #RRGGBB (empty removes it)') .option('--draft ', 'tag this element with the draft that touches it') .option('--group ', 'assign this component to a sibling group') .option('--ungroup', 'remove this component from its group') @@ -163,6 +165,8 @@ export function registerWriteCommands(program: Command): void { combine: options.combine, detach: options.detach, newId: options.id, + icon: options.icon, + colour: options.colour, }) printWriteResult(edited) } catch (error) { diff --git a/test-bun/compiled-scanner.test.ts b/test-bun/compiled-scanner.test.ts index d3194470..633bcd49 100644 --- a/test-bun/compiled-scanner.test.ts +++ b/test-bun/compiled-scanner.test.ts @@ -41,7 +41,7 @@ test.concurrent('the standalone CLI resolves an installed dependency of a local "import evidence from 'scan-evidence'; export default { id: 'local', async scan() { return evidence } }") await writeFile(path.join(root, 'package.json'), '{ "name": "sample" }') await writeFile(path.join(root, 'probe.local'), 'source for the local scanner') - await writePluginConfig(root, { scanners: [{ id: 'local', source: './plugin', include: ['*.local'] }], workSources: [] }) + await writePluginConfig(root, { scanners: [{ id: 'local', source: './plugin', include: ['*.local'] }], workSources: [], icons: [] }) await run([process.execPath, 'scripts/build.ts'], repository, { ...process.env, GROMA_BUILD_OUTFILE: binary, }) diff --git a/test-bun/icon-packs.test.ts b/test-bun/icon-packs.test.ts new file mode 100644 index 00000000..bdd77934 --- /dev/null +++ b/test-bun/icon-packs.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'bun:test' +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { attachElementIcons } from '../src/icon-packs.ts' +import { addPlugin, updatePlugin } from '../src/plugin-management.ts' +import { readPluginConfig } from '../src/scanner/modules/config.ts' +import { box } from './helpers.ts' + +async function project(prefix: string): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)) + await cp(path.resolve(import.meta.dir, '../test/fixtures/empty-project'), root, { recursive: true }) + return root +} + +/** A pack with one `database` icon; the SVG says which pack and version drew it. */ +async function writePack(directory: string, name: string, id: string, version = '1.0.0') { + const manifest = { name, version, groma: { icons: { id, icons: { database: './database.svg' } } } } + await mkdir(directory, { recursive: true }) + await writeFile(path.join(directory, 'package.json'), JSON.stringify(manifest)) + await writeFile(path.join(directory, 'database.svg'), `${id} ${version}`) + return manifest +} + +test.concurrent('an icon is the first configured pack that has its name, the pack it names, or the emoji itself', async () => { + const root = await project('groma-icon-resolve-') + try { + for (const id of ['first', 'second']) { + await writePack(path.join(root, id), `icons-${id}`, id) + await addPlugin(root, `./${id}`) + } + const elements = ['database', 'first:database', 'second:database', '📦', 'missing'] + .map((icon, index) => ({ ...box(`element-${index}`, 'component', { x: 0, y: 0, width: 1, height: 1 }), icon })) + await attachElementIcons(root, elements) + const [bare, first, second, emoji, missing] = elements.map(element => element.resolvedIcon) + expect(first).toMatchObject({ kind: 'image' }) + expect(bare).toEqual(first) + expect(second).toMatchObject({ kind: 'image' }) + expect(second).not.toEqual(first) + expect(emoji).toEqual({ kind: 'emoji', text: '📦' }) + expect(missing).toBeUndefined() + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.concurrent('updating an icon pack keeps its place in the configured order', async () => { + const root = await project('groma-icon-update-') + const name = `@example/icons-${randomUUID()}` + const versions: Record = {} + const server = Bun.serve({ port: 0, fetch(request) { + const url = new URL(request.url) + if (url.pathname.endsWith('.tgz')) return new Response(Bun.file(path.join(root, path.basename(url.pathname)))) + return Response.json({ name, versions, 'dist-tags': { latest: '1.1.0' } }) + } }) + try { + for (const version of ['1.0.0', '1.1.0']) { + const manifest = await writePack(path.join(root, 'package'), name, 'primary', version) + const pack = Bun.spawn(['tar', '-czf', `${version}.tgz`, 'package'], { cwd: root, stdout: 'ignore', stderr: 'pipe' }) + const error = await new Response(pack.stderr).text() + expect(await pack.exited, error).toBe(0) + versions[version] = { ...manifest, dist: { tarball: `${server.url.origin}/${version}.tgz` } } + } + await writePack(path.join(root, 'secondary'), 'icons-secondary', 'secondary') + const options = { registry: server.url.href, cacheRoot: path.join(root, 'cache') } + await addPlugin(root, `${name}@1.0.0`, options) + await addPlugin(root, './secondary', options) + await updatePlugin(root, 'primary', undefined, options) + expect((await readPluginConfig(root)).icons).toEqual([ + { id: 'primary', source: `${name}@1.1.0` }, { id: 'secondary', source: './secondary' }, + ]) + } finally { + await server.stop(true) + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/test-bun/iso-map.test.ts b/test-bun/iso-map.test.ts index d778173b..1f2b2618 100644 --- a/test-bun/iso-map.test.ts +++ b/test-bun/iso-map.test.ts @@ -7,7 +7,7 @@ import { ROOF_SHADOW, centredRect } from '../src/sheet/grid.ts' import { CONTAINER_FONT, GROUP_FONT, ISLAND_FONT, PLANE, buildingFont, curved, roofBlock, textPadding, textWidth } from '../src/sheet/measure.ts' import { sheetScene } from '../src/sheet/scene.ts' import type { Building, RoutePoint, SheetScene } from '../src/sheet/types.ts' -import type { Bounds, Point } from '../src/types.ts' +import type { Bounds, Point, ResolvedIcon } from '../src/types.ts' import type { ProjectProfile } from '../src/project-profile.ts' import { fitCamera, @@ -26,7 +26,7 @@ import { import type { ProjectedScene, ProjectionView } from '../src/viewers/web/iso/projection/project.ts' import { presentScene } from '../src/viewers/web/iso/view-motion/presentation.ts' import { NESTED_POSE, OVERHEAD_POSE } from '../src/viewers/web/iso/view-motion/orbit.ts' -import { surfaceLabelLayout } from '../src/viewers/web/iso/painting/text.ts' +import { surfaceLabel, surfaceLabelLayout } from '../src/viewers/web/iso/painting/text.ts' import { box, openclawFixtureRoot, repositoryRoot, viewerFixtureRoot, worldOf } from './helpers.ts' const profile = (title: string, overview: string): ProjectProfile => ({ @@ -318,6 +318,23 @@ function groundPoint(point: Point, view: ProjectionView): Point { } } +const pictured: ResolvedIcon = { kind: 'image', uri: 'data:image/svg+xml;base64,PHN2Zy8+' } + +/** A label draws an icon exactly when it has one, inside its own selectable area. */ +function assertIconInside(painted: ReturnType, layout: ReturnType, withIcon: boolean): void { + assert.ok(Array.isArray(painted.children)) + const images = painted.children.filter(child => child.tag === 'image') + assert.equal(images.length, withIcon ? 1 : 0) + for (const image of images) { + const iconX = Number(image.attributes.x) + const iconY = Number(image.attributes.y) + assert.ok(iconX >= layout.x && iconX + Number(image.attributes.width) <= layout.x + layout.width, + 'icon fits inside the selectable title width') + assert.ok(iconY >= layout.leader && iconY + Number(image.attributes.height) <= layout.height, + 'icon fits inside the reserved title height') + } +} + for (const mode of ['iso', '2d'] as const) { test.concurrent(`${mode} external labels clear their surfaces and roofs and fit the camera`, async () => { const world = await loadAnnotatedArchitecture(`${repositoryRoot}/test/fixtures/plain-view`) @@ -341,9 +358,12 @@ for (const mode of ['iso', '2d'] as const) { .map(face => screenBox(face.points.map(point => groundPoint(point, scene.view)))))) for (const viewport of [{ width: 1200, height: 400 }, { width: 400, height: 1200 }]) { const camera = fitCamera(scene.bounds, viewport) - for (const zoom of [camera.k / 2, camera.k, camera.k * 2, 4]) { + // Every label is checked without an icon and with one, which shares the label's band with the name. + const cases = [camera.k / 2, camera.k, camera.k * 2, 4].flatMap(zoom => [{ zoom, withIcon: false }, { zoom, withIcon: true }]) + for (const { zoom, withIcon } of cases) { const fitted = surfaces.map(({ rect, text, size }) => { - const layout = surfaceLabelLayout(text, size, zoom, scene.view) + const layout = surfaceLabelLayout(text, size, zoom, scene.view, withIcon) + assertIconInside(surfaceLabel(text, size, scene.view, 0, zoom, withIcon ? pictured : undefined), layout, withIcon) const origin = groundPoint(text.origin, scene.view) const label = { x: origin.x + layout.x / PLANE, y: origin.y, diff --git a/test-bun/lint-command.test.ts b/test-bun/lint-command.test.ts index 6c19d0a9..29e56f20 100644 --- a/test-bun/lint-command.test.ts +++ b/test-bun/lint-command.test.ts @@ -58,7 +58,7 @@ test.concurrent('lint checks fresh scanner evidence and reports failures without expect(failed.error).toContain('lint-fixture-failure') expect(await snapshot(path.join(root, 'groma'))).toEqual(withFailure) - await writePluginConfig(root, { scanners: [], workSources: [] }) + await writePluginConfig(root, { scanners: [], workSources: [], icons: [] }) const empty = await snapshot(path.join(root, 'groma')) expect((await command(root, 'lint')).code).toBe(0) expect((await command(root, 'duplicates')).code).toBe(1) diff --git a/test-bun/revision-comparison.test.ts b/test-bun/revision-comparison.test.ts index fb1e8408..97a22e0c 100644 --- a/test-bun/revision-comparison.test.ts +++ b/test-bun/revision-comparison.test.ts @@ -63,6 +63,17 @@ test.concurrent('source-only and own-content changes modify only their owner; fl expect(Object.values(comparison.components).some(item => item.status === 'added' || item.status === 'removed')).toBe(false) }) +test.concurrent('an icon or colour change alone modifies its component and is named as the reason', async () => { + const { before, oldSources } = await fixture() + for (const [field, value] of [['icon', 'database'], ['colour', 'blue']] as const) { + const next = structuredClone(before) + next.elements.find(item => item.id === 'payment')![field] = value + const compared = compareArchitecture(before, next, oldSources, oldSources) + expect(compared.components.payment!.status).toBe('modified') + expect(componentReasons(compared.components.payment).map(reason => reason.key)).toEqual([field]) + } +}) + test.concurrent('an id that changes kind keeps both elements, so both comparison directions place everything', () => { // `export` is a component in the older world and a container holding revision-control in the newer one. const unit = { x: 0, y: 0, width: 1, height: 1 } diff --git a/test-bun/scanner-compatibility.test.ts b/test-bun/scanner-compatibility.test.ts index 794c0201..8555a8c7 100644 --- a/test-bun/scanner-compatibility.test.ts +++ b/test-bun/scanner-compatibility.test.ts @@ -34,7 +34,7 @@ test.concurrent('a Groma mismatch blocks plugin code while another selection can await writeFile(path.join(root, 'package.json'), JSON.stringify({ dependencies: { react: '19.0.0' } })) const blocked = await plugin(root, 'blocked', '>=99.0.0', true) const eligible = await plugin(root, 'eligible', '*', false) - await writePluginConfig(root, { scanners: [blocked, eligible], workSources: [] }) + await writePluginConfig(root, { scanners: [blocked, eligible], workSources: [], icons: [] }) const before = await loadAnnotatedArchitecture(root) const registry = await loadScannerRegistry(root) expect(registry.scannerIds).toEqual(['eligible']) diff --git a/test-bun/scanner-exclusions.test.ts b/test-bun/scanner-exclusions.test.ts index 3189c6fc..5be25086 100644 --- a/test-bun/scanner-exclusions.test.ts +++ b/test-bun/scanner-exclusions.test.ts @@ -46,14 +46,14 @@ test.concurrent('fully excluded Vue sources skip readiness and scanning until th await cp(path.resolve(import.meta.dir, '../test/fixtures/vue-output'), path.join(root, 'fixtures/vue'), { recursive: true }) await buildPackage(artifact) const scanners = [{ id: 'vue', source: artifact, include: vue.groma.scanner.include }] - await writePluginConfig(root, { scanners, exclude: ['/fixtures/'], workSources: [] }) + await writePluginConfig(root, { scanners, exclude: ['/fixtures/'], workSources: [], icons: [] }) const readiness = await checkScannerReadiness(root) expect(readiness.map(item => item.project)).toEqual(['ready']) const excluded = await (await loadScannerRegistry(root)).collectObservations(root) expect(excluded.failures).toEqual([]) expect(excluded.observations).toEqual([]) - await writePluginConfig(root, { scanners, workSources: [] }) + await writePluginConfig(root, { scanners, workSources: [], icons: [] }) const included = await (await loadScannerRegistry(root)).collectObservations(root) expect(included.failures).toEqual([]) expect(included.observations.map(observation => observation.scanner.id)).toEqual(['vue']) @@ -74,17 +74,17 @@ test.concurrent('global and scanner exclusions keep readiness and scan hooks off }`) // The scanner's only candidate is excluded, so Groma calls neither hook. const scanners = [{ id: 'fixture', source: './plugin', include: ['**/*.fixture'], settings: { input: 'hidden/source.fixture' } }] - await writePluginConfig(root, { scanners, exclude: ['/hidden/'], workSources: [] }) + await writePluginConfig(root, { scanners, exclude: ['/hidden/'], workSources: [], icons: [] }) expect((await checkScannerReadiness(root)).map(item => item.project)).toEqual(['ready']) expect((await (await loadScannerRegistry(root)).collectObservations(root)).failures).toEqual([]) - await writePluginConfig(root, { scanners, workSources: [] }) + await writePluginConfig(root, { scanners, workSources: [], icons: [] }) expect((await checkScannerReadiness(root)).map(item => item.project)).toEqual(['blocked']) expect((await (await loadScannerRegistry(root)).collectObservations(root)).failures.map(failure => failure.scanner)) .toEqual(['fixture']) // The scanner's own list keeps its hooks off the input as well. - await writePluginConfig(root, { scanners: [{ ...scanners[0]!, exclude: ['/hidden/'] }], workSources: [] }) + await writePluginConfig(root, { scanners: [{ ...scanners[0]!, exclude: ['/hidden/'] }], workSources: [], icons: [] }) expect((await checkScannerReadiness(root)).map(item => item.project)).toEqual(['ready']) expect((await (await loadScannerRegistry(root)).collectObservations(root)).failures).toEqual([]) } finally { await rm(root, { recursive: true, force: true }) } @@ -205,7 +205,7 @@ test.concurrent('the lists of a scanner, written from its install defaults, foll const config = await readPluginConfig(root) expect(config.scanners.find(scanner => scanner.id === 'second')).toMatchObject({ include: ['**/*.ts', '**/*.html'], exclude: ['/src/other.ts'] }) await writePluginConfig(root, { exclude: ['/scripts/'], scanners: config.scanners.map(scanner => - scanner.id === 'first' ? { ...scanner, exclude: ['!/scripts/'] } : scanner), workSources: [] }) + scanner.id === 'first' ? { ...scanner, exclude: ['!/scripts/'] } : scanner), workSources: [], icons: [] }) const registry = await loadScannerRegistry(root) const files = Object.fromEntries((await registry.collectObservations(root)).observations .map(scan => [scan.scanner.id, scan.files.map(file => file.file).sort()])) diff --git a/test-bun/scanner-installation.test.ts b/test-bun/scanner-installation.test.ts index 5ec00af1..d056b03e 100644 --- a/test-bun/scanner-installation.test.ts +++ b/test-bun/scanner-installation.test.ts @@ -19,7 +19,7 @@ async function fixture() { await writeFile(path.join(root, 'Cargo.toml'), '[package]\nname="sample"\nversion="0.1.0"\n') // The registry packages, their tarballs and the scanner caches are test scaffolding, not project source. await writeFile(path.join(root, '.gitignore'), 'packages/\ncache/\ncolleague-cache/\n*.tgz\n') - await writePluginConfig(root, { scanners: [], workSources: [] }) + await writePluginConfig(root, { scanners: [], workSources: [], icons: [] }) const version = `1.0.${randomInt(1000000, 1000000000)}` const manifests: Record> = {} for (const id of ['react', 'rust']) { diff --git a/test-bun/scanner-release.test.ts b/test-bun/scanner-release.test.ts index 5559cdea..1077bbc8 100644 --- a/test-bun/scanner-release.test.ts +++ b/test-bun/scanner-release.test.ts @@ -147,7 +147,7 @@ test.concurrent('staged Backlog release installs its contract and supports npm s const source = `@groma/work-source-backlog@${adapter.version}` expect(JSON.parse(stdout)).toEqual({ installed: { id: 'backlog', kind: 'workSource', source, status: 'found' }, - config: { scanners: [], workSources: [{ id: 'backlog', source }] }, restoredVersion: adapter.version, + config: { scanners: [], workSources: [{ id: 'backlog', source }], icons: [] }, restoredVersion: adapter.version, }) } finally { server.stop(true); await rm(root, { recursive: true, force: true }) } }) diff --git a/test-bun/scanner-restore.test.ts b/test-bun/scanner-restore.test.ts index 1fd6b752..c4bb49f8 100644 --- a/test-bun/scanner-restore.test.ts +++ b/test-bun/scanner-restore.test.ts @@ -18,7 +18,7 @@ for (const present of [false, true]) { groma: { scanner: { id: 'local', entry: './index.js', include: ['**/*.local'] } } })) await writeFile(path.join(root, source, 'index.js'), 'throw new Error("Restore must not execute the scanner")') } - await writePluginConfig(root, { scanners: [{ id: 'local', source, include: ['**/*.local'] }], workSources: [] }) + await writePluginConfig(root, { scanners: [{ id: 'local', source, include: ['**/*.local'] }], workSources: [], icons: [] }) const selection = await readFile(path.join(root, 'groma/plugins.json'), 'utf8') const architecture = await loadAnnotatedArchitecture(root) if (present) expect(await installScanners(root)).toBe(0) diff --git a/test-bun/scanner-settings-lifecycle.test.ts b/test-bun/scanner-settings-lifecycle.test.ts index ba087a31..5304fdae 100644 --- a/test-bun/scanner-settings-lifecycle.test.ts +++ b/test-bun/scanner-settings-lifecycle.test.ts @@ -14,7 +14,7 @@ async function project() { await mkdir(path.join(root, 'ui')) await writeFile(path.join(root, 'ui/package.json'), JSON.stringify({ dependencies: { react: '19.0.0' } })) await writeFile(path.join(root, 'ui/view.fixture'), 'first') - await writePluginConfig(root, { scanners: [], workSources: [] }) + await writePluginConfig(root, { scanners: [], workSources: [], icons: [] }) const source = path.join(root, 'plugin') await mkdir(source) await writeFile(path.join(source, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', @@ -107,7 +107,7 @@ test.concurrent('live scanning updates healthy evidence, reports every failure a await writePluginConfig(root, { scanners: [ { id: 'fixture', source, include: ['**/*.fixture'] }, { id: 'broken', source: './broken', include: ['**/*.fixture'] }, { id: 'tool', source: './tool', include: ['**/*.fixture'] }, - ], workSources: [] }) + ], workSources: [], icons: [] }) let nextFold: (() => void) | undefined const session = await createScannerSession(root, { onFold() { nextFold?.() } }) const blocked = () => session.state.plugins.filter(item => item.status === 'blocked').map(item => item.id).sort() diff --git a/test-bun/scanner-update.test.ts b/test-bun/scanner-update.test.ts index 9accd1e5..4faa98a4 100644 --- a/test-bun/scanner-update.test.ts +++ b/test-bun/scanner-update.test.ts @@ -35,7 +35,7 @@ test.concurrent('npm updates resolve omitted and bare sources, pin compatible re versions[version] = { ...manifest, dist: { tarball: `${server.url.origin}/${version}.tgz` } } } const options = { registry: server.url.href, cacheRoot: path.join(root, 'cache') } - const config = { workSources: [], exclude: ['generated/**'], scanners: [ + const config = { workSources: [], icons: [], exclude: ['generated/**'], scanners: [ { id: 'other', source: './other', include: ['**/*.other'] }, // An update keeps the entry's own lists rather than the package's current defaults. { id: 'sample', source: `${name}@1.0.0`, settings: { mode: 'strict' }, include: ['src/**/*.sample'], exclude: ['**/*.gen.ts'] }, diff --git a/test-bun/source-coverage.test.ts b/test-bun/source-coverage.test.ts index 5bd2f12b..054a81a5 100644 --- a/test-bun/source-coverage.test.ts +++ b/test-bun/source-coverage.test.ts @@ -73,7 +73,7 @@ test.concurrent('a pattern decides with the whole list, so a later negation rest await writePluginConfig(root, { ...config, exclude: ['**/*.generated.ts', '!src/keep.generated.ts'] }) expect(await reason(root, 'src/keep.generated.ts')).toContain('read by first, second') await writePluginConfig(root, { exclude: ['/scripts/'], scanners: config.scanners.map(scanner => - scanner.id === 'first' ? { ...scanner, exclude: ['!/scripts/'] } : scanner), workSources: [] }) + scanner.id === 'first' ? { ...scanner, exclude: ['!/scripts/'] } : scanner), workSources: [], icons: [] }) const answer = await reason(root, 'scripts/hidden.ts') expect(answer).toContain('read by first') expect(answer).toContain('excluded for second') diff --git a/test-bun/system-curation.test.ts b/test-bun/system-curation.test.ts index 638bedcb..95099c76 100644 --- a/test-bun/system-curation.test.ts +++ b/test-bun/system-curation.test.ts @@ -131,6 +131,19 @@ test.concurrent('an external system is no destination, and a container needs a s } finally { await rm(root, { recursive: true, force: true }) } }) +test.concurrent('a record with an authored icon or colour is not absorbed by a combine', async () => { + const root = await repository() + try { + // `a` and `b` are sibling components; the combine would delete b's document and its appearance with it. + for (const appearance of [{ icon: '📦', colour: '' }, { icon: '', colour: 'blue' }]) { + await editArchitecture(root, { id: 'b', ...appearance }) + const before = await loadAnnotatedArchitecture(root) + await expect(editArchitecture(root, { id: 'a', combine: ['b'] })).rejects.toThrow('authored metadata') + expect((await loadAnnotatedArchitecture(root)).elements).toEqual(before.elements) + } + } finally { await rm(root, { recursive: true, force: true }) } +}) + test.concurrent('an authored concept row naming a moved or absorbed record refuses the change and writes nothing', async () => { const root = await repository() try { diff --git a/test-bun/tui-source.test.ts b/test-bun/tui-source.test.ts index 297437d7..3d340782 100644 --- a/test-bun/tui-source.test.ts +++ b/test-bun/tui-source.test.ts @@ -3,7 +3,7 @@ import { test } from 'bun:test' import type { WorkSnapshot } from '../src/types.ts' import { outlineStopKeys } from '../src/viewers/tui/navigation-details.ts' -import { taskRecordView } from '../src/viewers/tui/panes/details.ts' +import { detailsLines, outlineRowKey, taskRecordView } from '../src/viewers/tui/panes/details.ts' import { viewerTheme } from '../src/viewers/tui/atoms/theme.ts' import { detailsContentWidth } from '../src/viewers/tui/layout.ts' @@ -122,3 +122,26 @@ test.concurrent('a task modified file opens its diff and Escape returns to the r assert.equal(state.diffView, undefined) assert.deepEqual(state.taskRecord, { id: 'TASK-1', row: readingRow, details: emptyRecord }) }) + + +test.concurrent('element appearance stays readable at the fixed details width without moving its action cursor', () => { + const world = navigationWorld() + const element = { ...world.elements.find(item => item.kind === 'component')!, + code: [{ scanner: 'fixture', file: 'src/example.ts' }] } + const theme = viewerTheme() + const width = 28 + const structure = [{ file: 'src/example.ts', declarations: [ + { kind: 'function' as const, name: 'read', line: 1, visibility: 'public' as const, entry: false }, + ] }] + const before = detailsLines(theme, element, world, width, 'how', undefined, outlineRowKey('src/example.ts', 0), structure) + const decorated = { ...element, icon: 'architecture:database', colour: '#397ed1' } + const after = detailsLines(theme, decorated, world, width, 'how', undefined, outlineRowKey('src/example.ts', 0), structure) + const text = after.lines.map(line => line.map(part => part.text).join('')) + assert.ok(text.some(row => row.includes(decorated.icon))) + assert.ok(text.some(row => row.includes(decorated.colour))) + const appearance = text.filter(row => row.includes(decorated.icon) || row.includes(decorated.colour)) + assert.equal(appearance.length, 2) + assert.ok(appearance.every(row => row.length <= width)) + assert.ok(before.cursor !== undefined) + assert.equal(after.cursor, before.cursor + 2) +}) diff --git a/test-bun/viewer-scanner-availability.test.ts b/test-bun/viewer-scanner-availability.test.ts index 889d87f8..43c43c5e 100644 --- a/test-bun/viewer-scanner-availability.test.ts +++ b/test-bun/viewer-scanner-availability.test.ts @@ -41,7 +41,7 @@ for (const missing of [false, true]) { const root = await setup() try { const other = await plugin(root, 'other') - await writePluginConfig(root, { scanners: [other, ...(missing ? [{ id: 'typescript', source: path.join(root, 'missing'), include: ['**/*.ts'] }] : [])], workSources: [] }) + await writePluginConfig(root, { scanners: [other, ...(missing ? [{ id: 'typescript', source: path.join(root, 'missing'), include: ['**/*.ts'] }] : [])], workSources: [], icons: [] }) const records = await snapshot(root) const before = await loadAnnotatedArchitecture(root) expect(before.relationships.length).toBeGreaterThan(0) @@ -56,7 +56,7 @@ for (const missing of [false, true]) { test.concurrent('empty scanning does not write saved data or start scan callbacks', async () => { const root = await setup() try { - await writePluginConfig(root, { scanners: [], workSources: [] }) + await writePluginConfig(root, { scanners: [], workSources: [], icons: [] }) const before = await snapshot(root) expect(await scanRepository(root)).toEqual({ created: 0, refreshed: 0, matched: 0 }) const watcher = await watchScan(root, { onFold() { throw new Error('empty scanner selection ran') } }) @@ -70,7 +70,7 @@ test.concurrent('a live session reports failed tooling without preventing saved let session: Awaited> | undefined try { const scanner = await plugin(root, 'broken', true) - await writePluginConfig(root, { scanners: [scanner], workSources: [] }) + await writePluginConfig(root, { scanners: [scanner], workSources: [], icons: [] }) const before = await snapshot(root) session = await createScannerSession(root) await session.reconfigure() diff --git a/test-bun/web-sharing.test.ts b/test-bun/web-sharing.test.ts index b9b62fd3..27f398f8 100644 --- a/test-bun/web-sharing.test.ts +++ b/test-bun/web-sharing.test.ts @@ -4,10 +4,17 @@ import os from 'node:os' import path from 'node:path' import { test } from 'bun:test' import { EMPTY_WORK_SNAPSHOT, type WorkSource } from '@groma/work-source' +import { Resvg } from '@resvg/resvg-wasm' +import { accentColour, accentColours } from '../src/element-appearance.ts' import { loadProjectProfile, saveProjectProfile } from '../src/project-profile.ts' +import { mixColour, palettes } from '../src/viewers/web/atoms/theme.ts' import { exportWebViewer } from '../src/viewers/web/export.ts' import { startWebViewer } from '../src/viewers/web/server.ts' +import { ACCENT_TINT, ACCENT_TITLE } from '../src/viewers/web/iso/painting/scale.ts' +import { COVER_SIZE } from '../src/viewers/web/sharing/cover.ts' +import { generateCovers } from '../src/viewers/web/sharing/images.ts' import { sharingMetadata } from '../src/viewers/web/sharing/metadata.ts' +import { box, worldOf } from './helpers.ts' const fixture = path.resolve(import.meta.dir, '../test/fixtures/empty-project') const mappedFixture = path.resolve(import.meta.dir, '../test/fixtures/source-view') @@ -89,3 +96,58 @@ test.concurrent('Auto shares a light cover without inventing an unauthored proje assert.equal(fields.get('og:image'), 'https://example.test/architecture/auto/cover-light.png') assert.equal(fields.has('og:description'), false) }) + +/** How many pixels of a cover have exactly this #rrggbb colour. resvg, which generateCovers has loaded, decodes the PNG it wrote. */ +function pixelsOf(cover: Uint8Array, colour: string): number { + const { width, height } = COVER_SIZE + const renderer = new Resvg(`` + + ``) + const image = renderer.render() + try { + const [red, green, blue] = [1, 3, 5].map(start => parseInt(colour.slice(start, start + 2), 16)) + const pixels = image.pixels + let count = 0 + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index] === red && pixels[index + 1] === green && pixels[index + 2] === blue) count += 1 + } + return count + } finally { + image.free() + renderer.free() + } +} + +test.concurrent('a cover paints every surface in its rest colour and an accented one in its accent mixed into the theme paper', async () => { + const unit = { x: 0, y: 0, width: 1, height: 1 } + const world = worldOf([ + box('shop', 'system', unit), + { ...box('api', 'container', unit, { parent: 'observed:shop' }), colour: 'blue' }, + box('web', 'container', unit, { parent: 'observed:shop' }), + ]) + const covers = await generateCovers({ sheet: world.sheet, project: null }) + for (const theme of ['light', 'dark'] as const) { + // The cover renderer drops a style rule it cannot read, and a surface left without its fill is drawn black. + assert.equal(pixelsOf(covers[theme], '#000000'), 0) + assert.ok(pixelsOf(covers[theme], mixColour(palettes[theme].paper, accentColour('blue')!, ACCENT_TINT)) > 0) + } +}) + +/** WCAG relative luminance of a #rrggbb colour. */ +function luminance(colour: string): number { + const [red, green, blue] = [1, 3, 5].map(start => { + const channel = parseInt(colour.slice(start, start + 2), 16) / 255 + return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue! +} + +test.concurrent('the title of every named accent reads on its own surface in every theme', () => { + for (const [theme, palette] of Object.entries(palettes)) { + for (const [name, accent] of Object.entries(accentColours)) { + const title = luminance(mixColour(accent, palette.ink, ACCENT_TITLE)) + const surface = luminance(mixColour(palette.paper, accent, ACCENT_TINT)) + const contrast = (Math.max(title, surface) + 0.05) / (Math.min(title, surface) + 0.05) + assert.ok(contrast >= 4.5, `${name} in ${theme} has contrast ${contrast.toFixed(2)}`) + } + } +}) diff --git a/test-bun/web-startup.test.ts b/test-bun/web-startup.test.ts index 4cb7eaee..52f4ba14 100644 --- a/test-bun/web-startup.test.ts +++ b/test-bun/web-startup.test.ts @@ -141,7 +141,7 @@ for (const findsComponents of [true, false]) { }`) await writePluginConfig(root, { scanners: [ { id: 'fixture', source: './plugin', include: ['**/*.fixture'] }, { id: 'companion', source: './companion', include: ['**/*.companion'] }, - ], workSources: [] }) + ], workSources: [], icons: [] }) opening = startWebViewer(root, { port: 0, scan: true, workSource: emptyWorkSource(), onListening: listening.resolve }) const url = await listening.promise await Promise.all([scanning.promise, companionScanning.promise]) @@ -296,7 +296,7 @@ test.concurrent('simultaneous scanner setup submissions share one initial scan a method: 'POST', redirect: 'manual', body: new URLSearchParams({ projectName: 'Example', directory: 'groma' }), }) expect(initialized.status).toBe(303) - await writePluginConfig(root, { scanners: [{ id: 'fixture', source: './plugin', include: ['**/*.fixture'] }], workSources: [] }) + await writePluginConfig(root, { scanners: [{ id: 'fixture', source: './plugin', include: ['**/*.fixture'] }], workSources: [], icons: [] }) submissions = [0, 1].map(() => fetch(`${viewer!.url}/scanners`, { method: 'POST', redirect: 'manual', body: new FormData(), })) @@ -335,7 +335,7 @@ test.concurrent('startup does not report scanning when every selected source is async scan() { throw new Error('Excluded sources must not be scanned') }, }`) expect(await Bun.spawn(['git', 'init', '--quiet', root]).exited).toBe(0) - await writePluginConfig(root, { scanners: [{ id: 'fixture', source: './plugin', include: ['**/*.fixture'] }], exclude: ['*.fixture'], workSources: [] }) + await writePluginConfig(root, { scanners: [{ id: 'fixture', source: './plugin', include: ['**/*.fixture'] }], exclude: ['*.fixture'], workSources: [], icons: [] }) const phases: string[] = [] session = await createScannerSession(root, { scan: true, onProgress: update => phases.push(update.phase) }) await session.ready diff --git a/test-bun/web-stats.test.ts b/test-bun/web-stats.test.ts index 856b8ab2..bb52b3c9 100644 --- a/test-bun/web-stats.test.ts +++ b/test-bun/web-stats.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict' import { test } from 'bun:test' +import { accentColours } from '../src/element-appearance.ts' +import { colourCounts } from '../src/viewers/web/chrome/colour-legend.ts' import { c4Counts } from '../src/viewers/web/chrome/stats.ts' import { box, worldOf } from './helpers.ts' @@ -20,3 +22,13 @@ test.concurrent('header counts separate internal C4 levels from actors and exter assert.deepEqual(c4Counts(worldOf([])), { system: 0, container: 0, component: 0 }) assert.deepEqual(world, before) }) + +test.concurrent('the colour legend counts a colour once, whether elements name it or spell its hex', () => { + const grey = accentColours.grey! + const counts = colourCounts([ + { colour: 'grey' }, { colour: grey }, { colour: grey.toUpperCase() }, { colour: 'blue' }, { colour: '#123456' }, {}, + ]) + + assert.deepEqual(counts.map(({ label, count }) => [label, count]), [['#123456', 1], ['blue', 1], ['grey', 3]]) + assert.deepEqual(colourCounts([{}]), []) +}) diff --git a/test-bun/work-source-selection.test.ts b/test-bun/work-source-selection.test.ts index 7f4f1925..be4dc97d 100644 --- a/test-bun/work-source-selection.test.ts +++ b/test-bun/work-source-selection.test.ts @@ -32,7 +32,7 @@ test.concurrent('work-source selection controls reads and closes its watch when const root = await project() let watch: { close(): void | Promise } | undefined try { - await writePluginConfig(root, { scanners: [], workSources: [] }) + await writePluginConfig(root, { scanners: [], workSources: [], icons: [] }) const session = await workSourceSession(root) watch = session.watch(() => {}) expect((await session.read()).items).toEqual([]) @@ -50,7 +50,7 @@ test.concurrent('work-source selection controls reads and closes its watch when test.concurrent('a work-source version mismatch blocks loading before plugin code executes', async () => { const root = await project('>=99.0.0') try { - await writePluginConfig(root, { scanners: [], workSources: [{ id: 'tasks', source: './adapter' }] }) + await writePluginConfig(root, { scanners: [], workSources: [{ id: 'tasks', source: './adapter' }], icons: [] }) const selection = await checkWorkSourceReadiness(root) expect(selection?.status).toBe('blocked') expect(selection?.message).toContain('>=99.0.0') @@ -66,22 +66,22 @@ test.concurrent('a plugins.json with a second work-source entry is refused when const root = await project() const workSources = [{ id: 'tasks', source: './adapter' }, { id: 'other', source: './other' }] try { - await expect(writePluginConfig(root, { scanners: [], workSources })).rejects.toThrow('at most one work source') + await expect(writePluginConfig(root, { scanners: [], workSources, icons: [] })).rejects.toThrow('at most one work source') await handWritten(root, { scanners: [], workSources }) await expect(readPluginConfig(root)).rejects.toThrow('at most one work source') } finally { await rm(root, { recursive: true, force: true }) } }) -test.concurrent('a plugin id shared by a scanner and a work source is refused when read and when written', async () => { +test.concurrent('a plugin id shared by two sections is refused when read and when written', async () => { const root = await project() - const config = { - scanners: [{ id: 'tasks', source: './scanner', include: ['**/*.fixture'] }], - workSources: [{ id: 'tasks', source: './adapter' }], - } + const scanners = [{ id: 'tasks', source: './scanner', include: ['**/*.fixture'] }] + const shared = [{ id: 'tasks', source: './adapter' }] try { - await expect(writePluginConfig(root, config)).rejects.toThrow('more than once') - await handWritten(root, config) - await expect(readPluginConfig(root)).rejects.toThrow('more than once') + for (const config of [{ scanners, workSources: shared, icons: [] }, { scanners, workSources: [], icons: shared }]) { + await expect(writePluginConfig(root, config)).rejects.toThrow('more than once') + await handWritten(root, config) + await expect(readPluginConfig(root)).rejects.toThrow('more than once') + } } finally { await rm(root, { recursive: true, force: true }) } }) @@ -104,7 +104,7 @@ async function runWizard(root: string, overrides: Partial { const root = await project() try { - await writePluginConfig(root, { scanners: [], workSources: [{ id: 'backlog', source: '@groma/work-source-backlog@0.1.0' }] }) + await writePluginConfig(root, { scanners: [], workSources: [{ id: 'backlog', source: '@groma/work-source-backlog@0.1.0' }], icons: [] }) expect((await runWizard(root, { initializeBacklog: async () => false })).errors).toHaveLength(1) } finally { await rm(root, { recursive: true, force: true }) } }) @@ -113,7 +113,7 @@ test.concurrent('groma init leaves Backlog alone when the project selects anothe const root = await project() let initialized = 0 try { - await writePluginConfig(root, { scanners: [], workSources: [{ id: 'tasks', source: './adapter' }] }) + await writePluginConfig(root, { scanners: [], workSources: [{ id: 'tasks', source: './adapter' }], icons: [] }) await runWizard(root, { initializeBacklog: async () => { initialized++; return true } }) expect(initialized).toBe(0) } finally { await rm(root, { recursive: true, force: true }) }