Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{"_type":"issue","id":"stackwright-wao","title":"checkForPlaintextSecret: add high-entropy detection for real tokens (bi-directional check)","description":"The checkForPlaintextSecret function in packages/types/src/types/secret-detection.ts currently warns when entropy is low (\u003c 3.8) — catching human-readable plaintext passwords stored in integration auth YAML fields.\n\nHowever, there are two distinct threat classes that one threshold can't catch:\n- Low entropy (\u003c 3.8): human-readable passwords like \"password123\"\n- High entropy (\u003e 4.5): real cryptographic tokens like JWTs, bearer tokens, API keys stored directly in YAML\n\nThe current check only catches the first class. A JWT stored as `token: \"eyJhbGciOiJSUzI1NiJ9...\"` passes through silently.\n\nSuggested fix: change the condition to `entropy \u003c 3.8 || entropy \u003e 4.5` to catch both classes. Update the warning message to distinguish which case triggered.\n\nAdditional context: checkForPlaintextSecret is currently exported but never called (dead code). This issue should also cover wiring it into the prebuild pipeline at the integration auth processing point in packages/build-scripts/src/prebuild.ts. Consider adding a minimum length guard (e.g., skip values \u003c 32 chars for the high-entropy check) to reduce false positives from short but random-looking strings.","notes":"Tags: security, types, build-scripts","status":"open","priority":1,"issue_type":"task","owner":"bot@per-aspera.dev","created_at":"2026-05-19T01:40:56Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T01:40:56Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-a1g","title":"feat: Otter Agents AI-assisted site generation pipeline — complete remaining work","description":"Four-otter pipeline (Foreman, Brand, Theme, Page) core architecture is complete. Remaining work: end-to-end testing of full pipeline, create 3-5 example sites generated by the otter raft, refine handoff protocol between otters, design Collection Otter for Phase 2. Affects packages/otters. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/236","status":"open","priority":1,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:48Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-ufs","title":"feat(types,core): migrate to explicit type field discrimination in content renderer","description":"Content renderer currently uses Object.entries(item)[0] to discriminate content types — relies on JS object insertion order (not guaranteed), prevents TypeScript discriminated unions, produces poor error messages. Migrate to explicit type field on every content item (z.object({ type: z.literal('...'), ... })). Breaking change — coordinate with next major version bump. Acceptance: update Zod schemas, content renderer, TypeScript unions, all YAML files, tests, JSON schemas, AGENTS.md tables. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/344","status":"closed","priority":1,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:44Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:32Z","closed_at":"2026-05-19T00:22:32Z","close_reason":"Already implemented: contentRenderer.tsx uses item.type for discrimination, content.ts uses z.literal('type') on all schemas. Shipped before this triage run.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"stackwright-8je","title":"a11y: dark mode #FCC03E amber text on near-white backgrounds fails WCAG AA","description":"Dark theme uses #FCC03E (amber/yellow) for headings and sidebar links but darkColors resolves to near-white backgrounds (#fdfdfd, #f5f5f5, #f6f6f6), producing contrast ratios of 1.51–1.61. WCAG AA requires 4.5:1. Fix: darken darkColors backgrounds to ~#1a1a1a/#2a2a2a. Affects @stackwright/themes dark color configuration. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/439","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:07Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:57Z","closed_at":"2026-05-19T00:21:57Z","close_reason":"Fixed: changed darkColors.primary to #92400E and accent to #B45309 in stackwright-docs stackwright.yml in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0}
Expand Down
6 changes: 6 additions & 0 deletions .changeset/fix-map-component-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@stackwright/types": patch
"@stackwright/core": patch
---

Fix Map content type: assemble MapConfig from flat YAML props (was crashing on render), move Map component to base directory, tighten mapLayerSchema.data to z.unknown(), remove duplicate ZodLike declaration, fix checkForPlaintextSecret entropy threshold direction
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import React from 'react';
import { getMapProvider } from '../../map/map-registry.js';
import type { MapConfig, StackwrightMapProps } from '../../map/map-provider.js';
import type { MapConfig } from '../../map/map-provider.js';
import type { MapContent } from '@stackwright/types';

/**
* Props for the Map content component.
*
* We accept flat YAML fields (spread from the content renderer) rather than
* a pre-assembled `config` object. The `config: MapConfig` shape lives on
* MapProviderProps — that's the adapter contract. This component bridges
* the two: it reads flat schema fields and assembles the config before
* handing off to the registered MapProvider.
*
* `type` is omitted from MapContent and re-added as optional string so we
* can absorb the runtime value without TypeScript complaining — the content
* renderer spreads `{ type: 'map', ... }` and we don't want that reaching
* the DOM via `...rest`.
*/
type MapProps = Omit<MapContent, 'type'> & {
/** Absorbed at runtime from content renderer spread — not passed to DOM. */
type?: string;
/** Optional CSS class name for the wrapper div. */
className?: string;
/** Optional additional inline styles for the wrapper div. */
style?: React.CSSProperties;
/** Accessibility label. */
'aria-label'?: string;
};

/**
* Map — Content component for rendering interactive maps.
Expand Down Expand Up @@ -37,13 +63,23 @@ import type { MapConfig, StackwrightMapProps } from '../../map/map-provider.js';
* The Map component is SSR-safe. Map providers should use `useEffect`
* or `dynamic(() => import(), { ssr: false })` for client-only rendering.
*
* @param props - Map configuration and styling props
* @param props - Flat YAML schema fields for map configuration and styling
*/
export function Map(props: StackwrightMapProps & { config: MapConfig }): React.ReactElement {
export function Map(props: MapProps): React.ReactElement {
const MapProvider = getMapProvider();

const {
config,
// MapConfig fields — assembled into config object below
center,
zoom,
markers,
layers,
view,
terrain,
// BaseContent fields — absorbed so they don't reach the DOM via ...rest
label: _label,
type: _type,
// Display props
height = '500px',
width = '100%',
color,
Expand All @@ -53,7 +89,17 @@ export function Map(props: StackwrightMapProps & { config: MapConfig }): React.R
...rest
} = props;

// Responsive wrapper styles
// Assemble the provider-facing config from flat schema fields
const config: MapConfig = {
center,
zoom,
markers,
layers,
view,
terrain,
};

// Responsive wrapper — inline styles only (no Tailwind in core)
const wrapperStyle: React.CSSProperties = {
width,
height,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { ContactFormStub } from './ContactFormStub';
export { Alert } from './Alert';
export { LayoutGrid } from './LayoutGrid';
export { CollectionList } from './CollectionList';
export { Map } from './Map';
export { UnknownContentType } from './UnknownContentType';

export * from './Menu';
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export type { ConsentCategory, ConsentState } from './utils/consent';

// Map adapter system
export * from './map';
export { Map } from './components/content/Map';
// Map is exported via './components/base' (already covered by export * from './components/base' above)
export type {
MapMarker,
MapLayer,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils/componentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '../components/base/';
import { Media } from '../components/media/Media';
import { Timeline } from '../components/narrative/Timeline';
import { Map } from '../components/content/Map';
import { Map } from '../components/base/Map';
import NavSidebar from '../components/structural/NavSidebar';
import {
getStackwrightImage,
Expand Down
199 changes: 199 additions & 0 deletions packages/core/test/components/map.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Map } from '../../src/components/base/Map';
import { registerMapProvider, clearMapProvider } from '../../src/map/map-registry';
import type { MapProviderProps } from '../../src/map/map-provider';

// ---------------------------------------------------------------------------
// Mock MapProvider
//
// Renders a single div with data-testid="map-provider" and serializes the
// received `config` as `data-config` so tests can assert what the Map
// component assembled and passed down.
// ---------------------------------------------------------------------------

const MockMapProvider = ({ config }: MapProviderProps) => (
<div data-testid="map-provider" data-config={JSON.stringify(config)} />
);

// ---------------------------------------------------------------------------
// Shared test fixtures
// ---------------------------------------------------------------------------

const center = { lat: 37.7749, lng: -122.4194 };
const zoom = 12;

const minProps = { label: 'test-map', center, zoom };

// Helper to grab the parsed config from the mock provider's data attribute.
function getRenderedConfig(): Record<string, unknown> {
const provider = screen.getByTestId('map-provider');
return JSON.parse(provider.getAttribute('data-config') ?? '{}');
}

// ---------------------------------------------------------------------------
// Lifecycle — register/clear mock provider around every test
// ---------------------------------------------------------------------------

beforeEach(() => {
registerMapProvider(MockMapProvider);
});

afterEach(() => {
clearMapProvider();
});

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('Map component — smoke test', () => {
it('renders without crashing with the minimum valid props', () => {
render(<Map {...minProps} />);
expect(screen.getByTestId('map-provider')).toBeInTheDocument();
});
});

describe('Map component — config assembly (the P0 fix)', () => {
it('assembles a config object from flat center and zoom props', () => {
render(<Map {...minProps} />);
const config = getRenderedConfig();
// The key invariant: center and zoom must not be undefined in the config.
// Before the fix, Map expected a pre-built `config` prop — after the fix
// it builds the config itself from flat YAML fields.
expect(config.center).toEqual(center);
expect(config.zoom).toBe(zoom);
});

it('forwards optional markers into the assembled config', () => {
const markers = [{ lat: 37.7749, lng: -122.4194, label: 'SF HQ', popup: '123 Market St' }];
render(<Map {...minProps} markers={markers} />);
expect(getRenderedConfig().markers).toEqual(markers);
});

it('forwards optional layers into the assembled config', () => {
const layers = [
{
type: 'polyline' as const,
data: [
[37.7749, -122.4194],
[40.7128, -74.006],
],
style: { color: '#FF5733', width: 3 },
},
];
render(<Map {...minProps} layers={layers} />);
expect(getRenderedConfig().layers).toEqual(layers);
});

it('forwards optional view and terrain into the assembled config', () => {
render(<Map {...minProps} view="globe" terrain={true} />);
const config = getRenderedConfig();
expect(config.view).toBe('globe');
expect(config.terrain).toBe(true);
});

it('forwards all MapConfig fields together', () => {
const markers = [{ lat: center.lat, lng: center.lng, label: 'Pin' }];
render(<Map {...minProps} markers={markers} view="map" terrain={false} />);
const config = getRenderedConfig();
expect(config.center).toEqual(center);
expect(config.zoom).toBe(zoom);
expect(config.markers).toEqual(markers);
expect(config.view).toBe('map');
expect(config.terrain).toBe(false);
});
});

describe('Map component — DOM attribute hygiene', () => {
it('does not spread label onto the wrapper div', () => {
const { container } = render(<Map label="should-not-appear" center={center} zoom={zoom} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.getAttribute('label')).toBeNull();
});

it('does not spread type onto the wrapper div', () => {
const { container } = render(<Map label="test" type="map" center={center} zoom={zoom} />);
const wrapper = container.firstChild as HTMLElement;
// `type` is a valid HTML attribute on some elements but should be absorbed
// by the component, not forwarded to the outer div wrapper.
expect(wrapper.getAttribute('type')).toBeNull();
});

it('does not forward label to the inner MapProvider', () => {
render(<Map label="should-not-appear" center={center} zoom={zoom} />);
const provider = screen.getByTestId('map-provider');
expect(provider.getAttribute('label')).toBeNull();
});

it('does not forward type to the inner MapProvider', () => {
render(<Map label="test" type="map" center={center} zoom={zoom} />);
const provider = screen.getByTestId('map-provider');
expect(provider.getAttribute('type')).toBeNull();
});
});

describe('Map component — wrapper dimensions', () => {
it('applies default 500px height when no height prop is given', () => {
const { container } = render(<Map {...minProps} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.height).toBe('500px');
});

it('applies default 100% width when no width prop is given', () => {
const { container } = render(<Map {...minProps} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.width).toBe('100%');
});

it('respects an explicit string height prop', () => {
const { container } = render(<Map {...minProps} height="300px" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.height).toBe('300px');
expect(wrapper.style.minHeight).toBe('300px');
});

it('respects a numeric height prop and sets minHeight in px', () => {
const { container } = render(<Map {...minProps} height={400} />);
const wrapper = container.firstChild as HTMLElement;
// React converts numeric px values to "400px" in inline styles
expect(wrapper.style.height).toBe('400px');
expect(wrapper.style.minHeight).toBe('400px');
});

it('respects an explicit width prop', () => {
const { container } = render(<Map {...minProps} width="800px" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.width).toBe('800px');
});
});

describe('Map component — theming and styling', () => {
it('applies background color to the wrapper', () => {
const { container } = render(<Map {...minProps} background="#1a1a2e" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.background).toBe('rgb(26, 26, 46)');
});

it('applies color prop to the wrapper', () => {
const { container } = render(<Map {...minProps} color="#ffffff" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.style.color).toBe('rgb(255, 255, 255)');
});

it('passes className to the wrapper div', () => {
const { container } = render(<Map {...minProps} className="my-map-class" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.classList.contains('my-map-class')).toBe(true);
});

it('merges additional style props into wrapper without clobbering defaults', () => {
const { container } = render(<Map {...minProps} style={{ border: '2px solid red' }} />);
const wrapper = container.firstChild as HTMLElement;
// Default styles must still be there
expect(wrapper.style.borderRadius).toBe('8px');
// Additional style must be merged in
expect(wrapper.style.border).toBe('2px solid red');
});
});
Loading
Loading