diff --git a/frontend/e2e/settings-extensions.spec.ts b/frontend/e2e/settings-extensions.spec.ts index 0f10644f..d334dcee 100644 --- a/frontend/e2e/settings-extensions.spec.ts +++ b/frontend/e2e/settings-extensions.spec.ts @@ -4,12 +4,25 @@ import { clickAtlasTrayTool } from './fixtures/atlasTray' import { deleteViaContextMenu, shapeDrawPoints, shapeObjects } from './fixtures/atlasShapeTool' import { paletteDialog } from './fixtures/palette' -// Settings > Extensions (goal 0237 S2): a registry-derived list of -// every canvas tool, each toggleable off. Shared pool: the only global -// state this spec writes (Shape's own disabled flag) is restored to -// its default (enabled) before the file ends, same cleanup discipline -// display-density.spec.ts already establishes for a Settings toggle in -// the shared pool; every board object created here is deleted here. +// Settings > Extensions (goal 0237 S2, extended by goal 0237 S3's +// rider): a registry-derived list of every registered canvas NOUN -- +// every tray tool plus every tool-less noun (diagram, sheet -- native +// file-drop only, no tray button), each toggleable off. Shared pool: +// the only global state this spec writes (Shape's own disabled flag) +// is restored to its default (enabled) before the file ends, same +// cleanup discipline display-density.spec.ts already establishes for a +// Settings toggle in the shared pool; every board object created here +// is deleted here. +// +// Disabling diagram/sheet gates useAtlasNativeFileDrop.ts's own drop +// routing (a disabled drop falls through to the plain-card path). The +// OS drop GESTURE itself is a structural e2e gap (testing.md's own +// manual-only registry: WindowFilesDropped needs a real +// *WebviewWindow, which server-mode Playwright's connection is not), +// so the routing DECISION is proven at the honest layer instead -- +// useAtlasNativeFileDrop.test.ts's resolveFileDropKind Vitest suite -- +// and this spec only proves the row/toggle exists and states its +// narrower scope. async function openExtensionsSection(page: import('@playwright/test').Page) { await page.getByRole('link', { name: 'Settings' }).click() @@ -59,8 +72,10 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest await toggleAll.click() await expect(toggleAll).toHaveText('Turn all on') - // Every row but card now shows its toggle off. - for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) { + // Every row but card now shows its toggle off -- including the + // tool-less nouns (diagram, sheet), which have no tray button to + // empty but still participate in the bulk toggle. + for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) { const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button') await expect(toggle).toHaveAttribute('data-checked', 'false') } @@ -80,7 +95,7 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest await expect(toggleAll).toHaveText('Turn all on') await toggleAll.click() await expect(toggleAll).toHaveText('Turn all off') - for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) { + for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) { const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button') await expect(toggle).toHaveAttribute('data-checked', 'true') } @@ -90,9 +105,10 @@ test('Extensions section lists every registered canvas tool; the built-in card r await page.goto('/') await openExtensionsSection(page) - // Every ATLAS_TOOLS member (atlas/atlasTools.ts) gets exactly one - // row -- card, note, area, table, image, pencil, eraser, laser, shape. - await expect(page.getByTestId('extensions-row')).toHaveCount(9) + // Every ATLAS_TOOLS member (atlas/atlasTools.ts) plus every + // tool-less noun (diagram, sheet) gets exactly one row -- card, note, + // area, table, image, pencil, eraser, laser, shape, diagram, sheet. + await expect(page.getByTestId('extensions-row')).toHaveCount(11) const cardRow = page.locator('[data-testid="extensions-row"][data-extension-id="card"]') await expect(cardRow).toBeVisible() @@ -105,6 +121,40 @@ test('Extensions section lists every registered canvas tool; the built-in card r await expect(tableToggle).toHaveAttribute('data-checked', 'true') }) +test('A tool-less noun (diagram, sheet) gets a row with a toggle and states its narrower disable scope', async ({ page }) => { + await page.goto('/') + await openExtensionsSection(page) + + const diagramRow = page.locator('[data-testid="extensions-row"][data-extension-id="diagram"]') + await expect(diagramRow).toBeVisible() + await expect(diagramRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true') + await diagramRow.locator('summary').click() + await expect(diagramRow.getByTestId('extensions-row-description')).toHaveText( + 'View and edit diagrams — draw.io files open in the real editor.', + ) + await expect(diagramRow.getByTestId('extensions-row-disable-scope')).toHaveText( + 'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.', + ) + + const sheetRow = page.locator('[data-testid="extensions-row"][data-extension-id="sheet"]') + await expect(sheetRow).toBeVisible() + await expect(sheetRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true') + await sheetRow.locator('summary').click() + await expect(sheetRow.getByTestId('extensions-row-description')).toHaveText( + 'Preview spreadsheets and CSV files dropped onto the board.', + ) + await expect(sheetRow.getByTestId('extensions-row-disable-scope')).toHaveText( + 'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.', + ) + + // A tray tool's row never shows a disable-scope note -- its toggle's + // scope (tray button + palette command) is already the standing + // default every row implicitly shares. + const tableRow = page.locator('[data-testid="extensions-row"][data-extension-id="table"]') + await tableRow.locator('summary').click() + await expect(tableRow.getByTestId('extensions-row-disable-scope')).toHaveCount(0) +}) + test('Disabling a tool removes it from the tray and palette, keeps existing objects rendering, and persists across reload', async ({ page }) => { await page.goto('/') await page.getByRole('link', { name: 'Atlas' }).click() diff --git a/frontend/src/atlas/atlasNounDeclarationFields.json b/frontend/src/atlas/atlasNounDeclarationFields.json index 11c87f35..7244b593 100644 --- a/frontend/src/atlas/atlasNounDeclarationFields.json +++ b/frontend/src/atlas/atlasNounDeclarationFields.json @@ -77,7 +77,7 @@ { "field": "content", "legalValues": "an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null", - "meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it." + "meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy -- it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote) so Settings > Extensions can still render an honest row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it." }, { "field": "capabilities", diff --git a/frontend/src/atlas/atlasNounRegistry.ts b/frontend/src/atlas/atlasNounRegistry.ts index de1b8b00..f3fd23e9 100644 --- a/frontend/src/atlas/atlasNounRegistry.ts +++ b/frontend/src/atlas/atlasNounRegistry.ts @@ -93,6 +93,32 @@ export interface AtlasNounContent { // RESOLVER -- see EditRouteDecl's own header for why a single Kind // (diagram) needs the function form. editRoute?: EditRouteDecl + // extension (goal 0237 S3 rider): Settings > Extensions row metadata + // for a NOUN WITH NO TRAY TOOL. A tool-bearing noun already carries + // icon/label/description on its own AtlasToolShapeBase descriptor + // (registerNoun below), so this stays undefined there; a tool-less + // noun (diagram, sheet -- file-drop only, no AtlasToolShape to + // declare these on) sets it directly in its own registerBoardObjectContent + // call so the Extensions list can render an honest row for it too, + // mirroring goal 0211's own description-field precedent. Presence of + // this field is exactly how toolLessNounExtensions() below finds a + // tool-less noun worth listing. + extension?: ExtensionRowMeta +} + +// ExtensionRowMeta -- the fields ExtensionRow.tsx needs for a tool-less +// noun's own row that AtlasToolShapeBase would otherwise supply. +// disableScopeNote is REQUIRED (never optional): a tool-less noun has +// no tray button to hide, so its own disable toggle gates a narrower, +// noun-specific seam (file-drop routing, and for diagram the embedded- +// editor door) -- the row must always say so rather than silently +// implying the same tray-wide scope a tool row's toggle has. +export interface ExtensionRowMeta { + icon: Icon + label: string + description: string + disableScopeNote: string + capabilities?: readonly string[] } // AtlasBoardObjectContent -- AtlasNounContent plus the board-facts @@ -110,7 +136,7 @@ export interface AtlasNounContent { // than independently settable -- a Kind with no source still declares // this field directly (shape/ink today), so the field itself stays // required. -interface AtlasBoardObjectContent extends AtlasNounContent { +export interface AtlasBoardObjectContent extends AtlasNounContent { dragBand: boolean fileBacked: boolean } @@ -146,6 +172,34 @@ export function boardObjectContentFor(kind: string): AtlasBoardObjectContent | u return boardObjectContentRegistry.get(kind as AtlasBoardObjectKind) } +// ToolLessNounExtension -- one entry of toolLessNounExtensions() below, +// with `extension` already narrowed to non-optional (the filter that +// builds this array is the one place that check happens, so every +// consumer downstream gets a guaranteed ExtensionRowMeta instead of +// re-checking for undefined itself). +export interface ToolLessNounExtension { + kind: AtlasBoardObjectKind + content: AtlasBoardObjectContent + extension: ExtensionRowMeta +} + +// toolLessNounExtensions -- every registered noun with NO AtlasToolShape +// of its own (diagram, sheet: file-drop only) that has declared +// Extensions-row metadata, so Settings > Extensions (ExtensionsSection.tsx) +// can list it alongside every tray tool. A tool-bearing noun's content +// also lives in this same registry (registerNoun folds it in below) but +// never sets `extension`, so it's excluded here -- it already gets its +// own row from ATLAS_TOOLS directly. Sorted by kind for a stable, +// deterministic row order independent of import.meta.glob's own +// alphabetical file-discovery order. +export function toolLessNounExtensions(): ToolLessNounExtension[] { + const found: ToolLessNounExtension[] = [] + for (const [kind, content] of boardObjectContentRegistry.entries()) { + if (content.extension) found.push({ kind, content, extension: content.extension }) + } + return found.sort((a, b) => a.kind.localeCompare(b.kind)) +} + interface AtlasToolShapeBase { icon: Icon label: string diff --git a/frontend/src/atlas/tools/diagramNoun.ts b/frontend/src/atlas/tools/diagramNoun.ts index 2385f2f2..f9bada5e 100644 --- a/frontend/src/atlas/tools/diagramNoun.ts +++ b/frontend/src/atlas/tools/diagramNoun.ts @@ -1,4 +1,5 @@ import { lazy } from 'react' +import { FlowchartIcon } from '@primer/octicons-react' import type { BoardObject } from '../../../bindings/github.com/alicoding/mill/internal/domain/atlas/models' import { registerBoardObjectContent } from '../atlasNounRegistry' import { isDrawioEditableExtension } from '../atlasDiagramMirror' @@ -45,4 +46,16 @@ registerBoardObjectContent('diagram', { ? { kind: 'embedded-engine', engine: 'drawio' } : { kind: 'external-app' } ), + // extension (goal 0237 S3 rider): the Settings > Extensions row for + // this tool-less noun. disableScopeNote states its scope honestly -- + // there is no tray button to hide, so the toggle instead gates + // useAtlasNativeFileDrop.ts's own routing (a disabled drop falls + // through to the plain card path) and dispatchObjectEdit's + // embedded-engine arm (objectSeams.ts, already keyed off object.Kind). + extension: { + icon: FlowchartIcon, + label: 'Diagram', + description: 'View and edit diagrams — draw.io files open in the real editor.', + disableScopeNote: 'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.', + }, }) diff --git a/frontend/src/atlas/tools/sheetNoun.ts b/frontend/src/atlas/tools/sheetNoun.ts index be11b8bb..a9fb9680 100644 --- a/frontend/src/atlas/tools/sheetNoun.ts +++ b/frontend/src/atlas/tools/sheetNoun.ts @@ -1,4 +1,5 @@ import { lazy } from 'react' +import { ColumnsIcon } from '@primer/octicons-react' import { registerBoardObjectContent } from '../atlasNounRegistry' // Lazy-imported (React.lazy + Suspense, AtlasBoardObjectNode.tsx's own @@ -40,4 +41,16 @@ registerBoardObjectContent('sheet', { // directly. source: { kind: 'file', pathKey: 'mirrorPath' }, editRoute: { kind: 'external-app' }, + // extension (goal 0237 S3 rider): the Settings > Extensions row for + // this tool-less noun. disableScopeNote states its scope honestly -- + // there is no tray button to hide and no embedded editor to close + // (editRoute above is a static 'external-app', which dispatchObjectEdit + // never gates), so the toggle only affects + // useAtlasNativeFileDrop.ts's own routing. + extension: { + icon: ColumnsIcon, + label: 'Sheet', + description: 'Preview spreadsheets and CSV files dropped onto the board.', + disableScopeNote: 'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.', + }, }) diff --git a/frontend/src/atlas/useAtlasNativeFileDrop.test.ts b/frontend/src/atlas/useAtlasNativeFileDrop.test.ts new file mode 100644 index 00000000..e2dc2836 --- /dev/null +++ b/frontend/src/atlas/useAtlasNativeFileDrop.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { resolveFileDropKind } from './useAtlasNativeFileDrop' + +const alwaysEnabled = () => true +const alwaysDisabled = () => false + +describe('resolveFileDropKind (goal 0237 S3 rider)', () => { + it('routes a diagram path to "diagram" when the diagram extension is enabled', () => { + expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram') + }) + + // Regression: disabling diagram from Settings > Extensions has no + // tray button to remove (diagram is file-drop only), so it must + // instead fall the drop through to the generic card path. + it('falls a disabled diagram drop through to the "card" path', () => { + expect(resolveFileDropKind('/tmp/plan.drawio', alwaysDisabled)).toBe('card') + expect(resolveFileDropKind('/tmp/flow.mmd', (id) => id !== 'diagram')).toBe('card') + }) + + it('routes a sheet path to "sheet" when the sheet extension is enabled', () => { + expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled)).toBe('sheet') + expect(resolveFileDropKind('/tmp/data.csv', alwaysEnabled)).toBe('sheet') + }) + + // Regression: same fall-through as diagram -- sheet has no tray + // button either. + it('falls a disabled sheet drop through to the "card" path', () => { + expect(resolveFileDropKind('/tmp/data.xlsx', alwaysDisabled)).toBe('card') + expect(resolveFileDropKind('/tmp/data.csv', (id) => id !== 'sheet')).toBe('card') + }) + + it('routes an image path to "image" regardless of diagram/sheet enablement', () => { + expect(resolveFileDropKind('/tmp/photo.png', alwaysDisabled)).toBe('image') + }) + + it('falls an unrelated extension through to "card"', () => { + expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card') + }) +}) diff --git a/frontend/src/atlas/useAtlasNativeFileDrop.ts b/frontend/src/atlas/useAtlasNativeFileDrop.ts index 1761d1e1..1a142d4c 100644 --- a/frontend/src/atlas/useAtlasNativeFileDrop.ts +++ b/frontend/src/atlas/useAtlasNativeFileDrop.ts @@ -4,6 +4,7 @@ import { Events } from '@wailsio/runtime' import { AtlasService } from '../shared/bindings' import { titleFromFilename } from './atlasCreateHelpers' import { refreshAtlas } from './atlasStore' +import { isExtensionEnabled } from '../shared/extensionEnablementStore' import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest' import { FILE_DROP_EVENT_NAME, FILE_DROP_CONTEXT_BOARD } from './atlasFileDropShared' import { frameContainingPoint } from './atlasFramePoint' @@ -16,6 +17,26 @@ const DROP_ERROR_MS = 4000 const PULSE_MS = 1200 const PULSE_MS_REDUCED = 1500 +// resolveFileDropKind -- the pure routing decision behind the Events.On +// handler below: which board-object kind a resolved drop path lands +// as, or 'card' for the generic reference-card fallback. Pulled out as +// its own function (goal 0237 S3 rider) purely so it's Vitest-testable +// on its own -- the real OS drop GESTURE that reaches it is a +// structural e2e gap (testing.md's own manual-only registry: +// WindowFilesDropped needs a real *WebviewWindow, which server-mode +// Playwright's connection is not), but this decision is plain data in, +// data out. isEnabled is injected rather than read from the store +// directly so a test can drive both branches without touching global +// state. +export type FileDropKind = 'diagram' | 'image' | 'sheet' | 'card' + +export function resolveFileDropKind(path: string, isEnabled: (id: string) => boolean): FileDropKind { + if (isDiagramPath(path) && isEnabled('diagram')) return 'diagram' + if (isImagePath(path)) return 'image' + if (isSheetPath(path) && isEnabled('sheet')) return 'sheet' + return 'card' +} + // The board-context half of the native OS file-drop door (goal 0081 // slice A3, LOCKED design §3b): a single non-directory file lands // instantly at the drop point (a brief settle pulse, the ⌘K jump @@ -63,34 +84,31 @@ export function useAtlasNativeFileDrop({ parentID, topLevelBoxes, screenToFlowPo return } const path = route.Path - // A dropped .drawio/.mmd/.mermaid file lands as a "diagram" - // board object, never a card (goal 0179 S2) -- no pulse/ - // duplicate-notice machinery applies, since a board object - // carries neither. - if (isDiagramPath(path)) { - return diagramCreate.land(path, targetParentID, { X: point.x, Y: point.y }) - } - // A dropped image file lands as an "image" board object the - // same way (goal 0206, 0179's founding rule): the reference- - // card fallback below is scoped to non-image, non-diagram - // drops now. - if (isImagePath(path)) { - return imageCreate.land(path, targetParentID, { X: point.x, Y: point.y }) - } - // A dropped .xlsx/.csv file lands as a "sheet" board object - // the same way (goal 0232 S2) -- the reference-card fallback - // below is scoped to non-image, non-diagram, non-sheet drops. - if (isSheetPath(path)) { - return sheetCreate.land(path, targetParentID, { X: point.x, Y: point.y }) + // A dropped .drawio/.mmd/.mermaid or .xlsx/.csv file lands as + // its own board object, never a card (goal 0179 S2, goal + // 0232 S2) -- no pulse/duplicate-notice machinery applies to + // either, since a board object carries neither. Disabling + // diagram/sheet from Settings > Extensions (goal 0237 S3 + // rider -- neither noun has a tray button to hide) falls the + // drop through to the plain-card path instead, exactly the + // resolveFileDropKind decision above states. + switch (resolveFileDropKind(path, isExtensionEnabled)) { + case 'diagram': + return diagramCreate.land(path, targetParentID, { X: point.x, Y: point.y }) + case 'image': + return imageCreate.land(path, targetParentID, { X: point.x, Y: point.y }) + case 'sheet': + return sheetCreate.land(path, targetParentID, { X: point.x, Y: point.y }) + case 'card': + return AtlasService.CreateCardFromFileDrop(path, titleFromFilename(path), targetParentID, { X: point.x, Y: point.y }) + .then((result) => refreshAtlas().then(() => { + pulse(result.Card.ID) + window.setTimeout(() => pulse(null), reduced ? PULSE_MS_REDUCED : PULSE_MS) + if (result.DuplicateOfTitle) { + setDropDuplicateNotice(t('board.dropDuplicateNotice', { title: result.DuplicateOfTitle })) + } + })) } - return AtlasService.CreateCardFromFileDrop(path, titleFromFilename(path), targetParentID, { X: point.x, Y: point.y }) - .then((result) => refreshAtlas().then(() => { - pulse(result.Card.ID) - window.setTimeout(() => pulse(null), reduced ? PULSE_MS_REDUCED : PULSE_MS) - if (result.DuplicateOfTitle) { - setDropDuplicateNotice(t('board.dropDuplicateNotice', { title: result.DuplicateOfTitle })) - } - })) }) .catch(() => setDropError(t('capture.dropError'))) }) diff --git a/frontend/src/views/ExtensionRow.tsx b/frontend/src/views/ExtensionRow.tsx index 6d8435c7..96cdbcce 100644 --- a/frontend/src/views/ExtensionRow.tsx +++ b/frontend/src/views/ExtensionRow.tsx @@ -1,9 +1,8 @@ import { ChevronRightIcon } from '@primer/octicons-react' import { useTranslation } from 'react-i18next' import { Label, Link, Stack, Text, ToggleSwitch } from '@primer/react' -import type { AtlasToolShape } from '../atlas/atlasTools' import { useAppStore } from '../shared/store' -import { descriptionLabel, editRouteLabel, groupLabel, reachLabel, sourceLabel, versionLabel } from './extensionMeta' +import { descriptionLabel, editRouteLabel, groupLabel, reachLabel, sourceLabel, versionLabel, type ExtensionRowSource } from './extensionMeta' import listStyles from '../shared/ListCard.module.css' import styles from './ExtensionsSection.module.css' @@ -15,46 +14,53 @@ const ATLAS_CONCEPTS_DOCS_PAGE = 'concepts/atlas.md' // ExtensionRow -- one row of Settings > Extensions, collapsed to // icon/label/meta by default, expanding (native
, see // ExtensionsSection.module.css's own header comment) into a registry- -// derived detail panel: description, meta chips, the honest reach -// line, the app's own build version, and the shared Docs link. Every -// value here is READ off the tool's own registered descriptor -// (atlas/atlasNounRegistry.ts) -- nothing here is hand-curated per -// extension. -export function ExtensionRow({ tool, builtIn, enabled, appVersion, onToggle }: { - tool: AtlasToolShape +// derived detail panel: description, meta chips, an optional disable- +// scope note, the honest reach line, the app's own build version, and +// the shared Docs link. Every value here is READ off the noun's own +// registered descriptor, normalized into ExtensionRowSource by +// extensionMeta.ts's toolRowSource/toolLessRowSource -- nothing here is +// hand-curated per extension, and this component never itself branches +// on whether the row came from a tray tool or a tool-less noun. +export function ExtensionRow({ row, builtIn, enabled, appVersion, onToggle }: { + row: ExtensionRowSource builtIn: boolean enabled: boolean appVersion: string onToggle: (enabled: boolean) => void }) { const { t } = useTranslation('views') - const Icon = tool.icon - const labelId = `extension-row-label-${tool.id}` - const meta = [groupLabel(tool.group), sourceLabel(tool.content?.source), editRouteLabel(tool.content?.editRoute)] + const Icon = row.icon + const labelId = `extension-row-label-${row.id}` + const meta = [row.group ? groupLabel(row.group) : null, sourceLabel(row.source), editRouteLabel(row.editRoute)] .filter((m): m is string => m !== null) return ( -
+
- {tool.label} + {row.label} {meta.length > 0 && ( {meta.join(' · ')} )}
- {descriptionLabel(tool)} + {descriptionLabel(row)} {meta.length > 0 && ( {meta.map((chip) => )} )} + {row.disableScopeNote && ( + + {row.disableScopeNote} + + )} - {reachLabel(tool.capabilities)} + {reachLabel(row.capabilities)} {appVersion && ( diff --git a/frontend/src/views/ExtensionsSection.tsx b/frontend/src/views/ExtensionsSection.tsx index d3066279..00f86838 100644 --- a/frontend/src/views/ExtensionsSection.tsx +++ b/frontend/src/views/ExtensionsSection.tsx @@ -2,33 +2,45 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActionList, Button, Stack, Text } from '@primer/react' import { ATLAS_TOOLS, type AtlasToolID } from '../atlas/atlasTools' +import { toolLessNounExtensions } from '../atlas/atlasNounRegistry' import { SettingsService } from '../shared/bindings' import { refreshDisabledExtensions, useExtensionEnablementStore } from '../shared/extensionEnablementStore' import { ExtensionRow } from './ExtensionRow' +import { toolLessRowSource, toolRowSource, type ExtensionRowSource } from './extensionMeta' import styles from '../shared/ListCard.module.css' // Settings > Extensions (goal 0237 S2, extended by goal 0211's plugin- -// manager UX slice): a registry-DERIVED list of every registered -// canvas tool, each expandable into a registry-derived detail panel -// (ExtensionRow.tsx) -- never a hand-curated array, so a new tool's own -// registerNoun() call (atlasNounRegistry.ts) makes it appear here with -// zero edits to this file. `card` is the one exception: it's the +// manager UX slice, and by goal 0237 S3's rider): a registry-DERIVED +// list of every registered NOUN -- every tray tool (ATLAS_TOOLS) plus +// every tool-less noun that declares Extensions-row metadata +// (toolLessNounExtensions(), diagram/sheet today) -- each expandable +// into a registry-derived detail panel (ExtensionRow.tsx) via one +// normalized row shape (extensionMeta.ts's ExtensionRowSource). Never a +// hand-curated array: a new tray tool's registerNoun() call or a new +// tool-less noun's own `extension` declaration both make it appear here +// with zero edits to this file. `card` is the one exception: it's the // kernel knowledge object (ADR-0046), not a guest extension, so its row // renders a "Built-in" label instead of a toggle -- shown rather than // omitted, so the Extensions list still reads as a complete inventory -// of every canvas tool, not a mysteriously-short one. +// of every canvas noun, not a mysteriously-short one. // -// Disabling a tool here only changes what CAN be created from now on: -// it removes the tool's own button from the creation tray +// Disabling a TRAY tool here only changes what CAN be created from now +// on: it removes the tool's own button from the creation tray // (AtlasCreationTray.tsx's own ATLAS_TOOLS.filter) and its // `atlas.create.` command from the palette/keyboard // (shared/commands.ts's own `enabled()` predicate) -- existing board // objects of that kind keep rendering exactly as before, since neither // the board's own render path nor AtlasBoardObjectNode.tsx's content // lookup (atlasNounRegistry.ts's boardObjectContentFor) ever consults -// this list at all. +// this list at all. A TOOL-LESS noun has no tray button to remove -- +// its own row states its narrower disable scope directly (see each +// noun's own `extension.disableScopeNote`, atlasNounRegistry.ts). const CARD_TOOL_ID: AtlasToolID = 'card' -const NON_BUILT_IN_IDS: AtlasToolID[] = ATLAS_TOOLS.filter((t) => t.id !== CARD_TOOL_ID).map((t) => t.id) +const EXTENSION_ROWS: ExtensionRowSource[] = [ + ...ATLAS_TOOLS.map(toolRowSource), + ...toolLessNounExtensions().map(toolLessRowSource), +] +const NON_BUILT_IN_IDS: string[] = EXTENSION_ROWS.filter((r) => r.id !== CARD_TOOL_ID).map((r) => r.id) export default function ExtensionsSection() { const { t } = useTranslation('views') @@ -40,7 +52,7 @@ export default function ExtensionsSection() { SettingsService.AppVersion().then(setAppVersion).catch(console.error) }, []) - const toggle = (id: AtlasToolID, enabled: boolean) => { + const toggle = (id: string, enabled: boolean) => { SettingsService.SetExtensionEnabled(id, enabled).then(refreshDisabledExtensions).catch(console.error) } @@ -77,14 +89,14 @@ export default function ExtensionsSection() { - {ATLAS_TOOLS.map((tool) => ( - + {EXTENSION_ROWS.map((row) => ( + toggle(tool.id, enabled)} + onToggle={(enabled) => toggle(row.id, enabled)} /> ))} diff --git a/frontend/src/views/extensionMeta.test.ts b/frontend/src/views/extensionMeta.test.ts index a95dc917..f57c18e2 100644 --- a/frontend/src/views/extensionMeta.test.ts +++ b/frontend/src/views/extensionMeta.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { descriptionLabel, editRouteLabel, groupLabel, reachLabel, sourceLabel, versionLabel } from './extensionMeta' +import { ATLAS_TOOLS } from '../atlas/atlasTools' +import { toolLessNounExtensions } from '../atlas/atlasNounRegistry' +import { + descriptionLabel, editRouteLabel, groupLabel, reachLabel, sourceLabel, versionLabel, + toolLessRowSource, toolRowSource, +} from './extensionMeta' describe('groupLabel', () => { it('maps every declared group to user-facing text', () => { @@ -68,3 +73,38 @@ describe('versionLabel', () => { expect(versionLabel('1.2.3')).toBe('Ships with Mill v1.2.3') }) }) + +describe('toolRowSource (goal 0237 S3 rider)', () => { + it('normalizes a tray tool into a row, carrying its group and content declarations', () => { + const shape = ATLAS_TOOLS.find((t) => t.id === 'shape')! + const row = toolRowSource(shape) + expect(row).toEqual({ + id: 'shape', + icon: shape.icon, + label: shape.label, + description: shape.description, + group: shape.group, + source: shape.content?.source, + editRoute: shape.content?.editRoute, + capabilities: shape.capabilities, + }) + }) +}) + +describe('toolLessRowSource (goal 0237 S3 rider)', () => { + it('normalizes a tool-less noun into a row, with no group chip and its own disableScopeNote', () => { + const diagram = toolLessNounExtensions().find((e) => e.kind === 'diagram')! + const row = toolLessRowSource(diagram) + expect(row).toEqual({ + id: 'diagram', + icon: diagram.extension.icon, + label: diagram.extension.label, + description: diagram.extension.description, + source: diagram.content.source, + editRoute: diagram.content.editRoute, + capabilities: diagram.extension.capabilities, + disableScopeNote: diagram.extension.disableScopeNote, + }) + expect(row.group).toBeUndefined() + }) +}) diff --git a/frontend/src/views/extensionMeta.ts b/frontend/src/views/extensionMeta.ts index ba0d3064..d30b6560 100644 --- a/frontend/src/views/extensionMeta.ts +++ b/frontend/src/views/extensionMeta.ts @@ -1,5 +1,7 @@ +import type { Icon } from '@primer/octicons-react' import type { EditRouteDecl, ObjectSource } from '../atlas/objectSeams' import type { AtlasToolShape } from '../atlas/atlasTools' +import type { ToolLessNounExtension } from '../atlas/atlasNounRegistry' // Pure enum -> user-vocabulary mapping for the Extensions section // (Settings > Extensions). Kept in its own file, apart from @@ -10,6 +12,62 @@ import type { AtlasToolShape } from '../atlas/atlasTools' // keeps its own pure formatter/description logic inline since none of // them branch on an enum this way. +// ExtensionRowSource -- the ONE row shape ExtensionRow.tsx renders, +// normalized from either a tray tool (AtlasToolShape) or a tool-less +// noun (ToolLessNounExtension) by the two builders below -- so the row +// component itself never branches on which kind of noun it's showing. +// group/description/capabilities/disableScopeNote stay optional: a +// tool-less noun has no tray cluster to report (there is no `group` +// chip for a noun with no tray at all) and only IT ever sets +// disableScopeNote, since only it needs to say its toggle's scope +// differs from a tray tool's. +export interface ExtensionRowSource { + id: string + icon: Icon + label: string + description?: string + group?: AtlasToolShape['group'] + source?: ObjectSource + editRoute?: EditRouteDecl + capabilities?: readonly string[] + disableScopeNote?: string +} + +// toolRowSource -- every ATLAS_TOOLS member becomes a row exactly as it +// already did before goal 0237 S3's rider (ExtensionsSection.tsx used +// to read AtlasToolShape fields directly); this is that same read, +// pulled out so it composes with toolLessRowSource below into one list. +export function toolRowSource(tool: AtlasToolShape): ExtensionRowSource { + return { + id: tool.id, + icon: tool.icon, + label: tool.label, + description: tool.description, + group: tool.group, + source: tool.content?.source, + editRoute: tool.content?.editRoute, + capabilities: tool.capabilities, + } +} + +// toolLessRowSource -- a tool-less noun's own row (goal 0237 S3 rider): +// icon/label/description/disableScopeNote come from its declared +// `extension` (atlasNounRegistry.ts's ExtensionRowMeta), source/ +// editRoute from the same content declaration a tray tool's row reads, +// and no `group` -- there is no tray cluster to report honestly. +export function toolLessRowSource({ kind, content, extension }: ToolLessNounExtension): ExtensionRowSource { + return { + id: kind, + icon: extension.icon, + label: extension.label, + description: extension.description, + source: content.source, + editRoute: content.editRoute, + capabilities: extension.capabilities, + disableScopeNote: extension.disableScopeNote, + } +} + // groupLabel -- AtlasToolShape.group's own three clusters, restated as // short chip text a user would recognize (never the tray's own // internal cluster name). diff --git a/userdocs/llms-full.txt b/userdocs/llms-full.txt index d530744d..a2eacf74 100644 --- a/userdocs/llms-full.txt +++ b/userdocs/llms-full.txt @@ -1420,7 +1420,7 @@ are never omissions. | `boardNodeType` | 'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null | which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser). | | `dragBand` | boolean -- REQUIRED, never optional | only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted. | | `boardObjectKind` | 'shape', 'image', 'ink', 'table', 'diagram', 'sheet', or null | the persisted BoardObject.Kind this noun's own placed instance carries, or null for a tool that never routes through the shared 'atlas-object' renderer. Not always equal to id -- pencil's own placed instance is Kind 'ink' -- so content resolution below keys off this field, read from object.Kind, never off id. | -| `content` | an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null | this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it. | +| `content` | an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null | this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy -- it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote) so Settings > Extensions can still render an honest row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it. | | `capabilities` | a readonly array of strings, or omitted entirely | the external reach this noun's own manifest declares. No current noun sets it. Settings > Extensions' reach line reads this field directly, so a future noun's declared capabilities show up there with no other code change. | | `fileBacked` | boolean -- REQUIRED, never optional | does this noun's own placed instance read Payload.mirrorPath as a real external file (goal 0232's file-backed preview/open/watch contract)? true gets the shared live-watch subscription (its own content Component sees mirrorVersion bump) and the object.openInDefaultApp context-menu command uniformly, with no extra wiring of either. A noun with no boardObjectKind at all still declares it, always false. | | `sticky` | boolean -- REQUIRED, never optional | does this tool stay armed after a completed gesture (pencil/eraser/laser -- repeated strokes/passes are the point), or disarm after one? useAtlasToolGesture.ts reads this to decide whether a gesture's own onEnd may call ctx.disarm/disarmUnlessLocked at all -- a sticky tool gets no-ops for both. A non-drag tool still declares it, always false. | diff --git a/userdocs/reference/extending-the-canvas.md b/userdocs/reference/extending-the-canvas.md index 8eea2601..7066da64 100644 --- a/userdocs/reference/extending-the-canvas.md +++ b/userdocs/reference/extending-the-canvas.md @@ -85,7 +85,7 @@ are never omissions. | `boardNodeType` | 'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null | which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser). | | `dragBand` | boolean -- REQUIRED, never optional | only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted. | | `boardObjectKind` | 'shape', 'image', 'ink', 'table', 'diagram', 'sheet', or null | the persisted BoardObject.Kind this noun's own placed instance carries, or null for a tool that never routes through the shared 'atlas-object' renderer. Not always equal to id -- pencil's own placed instance is Kind 'ink' -- so content resolution below keys off this field, read from object.Kind, never off id. | -| `content` | an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null | this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it. | +| `content` | an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null | this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy -- it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote) so Settings > Extensions can still render an honest row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it. | | `capabilities` | a readonly array of strings, or omitted entirely | the external reach this noun's own manifest declares. No current noun sets it. Settings > Extensions' reach line reads this field directly, so a future noun's declared capabilities show up there with no other code change. | | `fileBacked` | boolean -- REQUIRED, never optional | does this noun's own placed instance read Payload.mirrorPath as a real external file (goal 0232's file-backed preview/open/watch contract)? true gets the shared live-watch subscription (its own content Component sees mirrorVersion bump) and the object.openInDefaultApp context-menu command uniformly, with no extra wiring of either. A noun with no boardObjectKind at all still declares it, always false. | | `sticky` | boolean -- REQUIRED, never optional | does this tool stay armed after a completed gesture (pencil/eraser/laser -- repeated strokes/passes are the point), or disarm after one? useAtlasToolGesture.ts reads this to decide whether a gesture's own onEnd may call ctx.disarm/disarmUnlessLocked at all -- a sticky tool gets no-ops for both. A non-drag tool still declares it, always false. |