Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cd185d4
fix(ar): ship non-zero poi-layer grouping defaults and document the t…
ludvigovrevik Jul 23, 2026
266f58a
feat(ar): configurable x-filter cutoff on poi targets
ludvigovrevik Jul 23, 2026
65a7011
fix(ar)!: unify poi line-length default at 192 and drop the dead laye…
ludvigovrevik Jul 23, 2026
ddcbdc5
fix(ar)!: expose poi-group positionVertical as a position-vertical at…
ludvigovrevik Jul 23, 2026
6f45815
chore: fail wrapper generation loudly on Windows path-separator corru…
ludvigovrevik Jul 23, 2026
f1e0169
docs(ar): document assembly requirements, coordinate model, and missi…
ludvigovrevik Jul 23, 2026
4f946e0
feat(ar): export controller layer-marker constants
ludvigovrevik Jul 23, 2026
5afc36a
test(ar): await settle in grouped poi-group stories
ludvigovrevik Jul 23, 2026
e9bb413
feat(ar): public selection API on obc-poi-layer-stack
ludvigovrevik Jul 23, 2026
267c464
feat(ar): grouping-change event on obc-poi-layer
ludvigovrevik Jul 23, 2026
9f4a9b6
feat(ar): export media-projection math as pure functions
ludvigovrevik Jul 23, 2026
620b158
feat(ar): built-in y low-pass filter on poi targets
ludvigovrevik Jul 23, 2026
aa8f422
feat(ar): variant, icon, state, and data support in the controller de…
ludvigovrevik Jul 23, 2026
974f0ae
docs(ar): document poi-card close-click and expose CSS shadow parts
ludvigovrevik Jul 23, 2026
7da15df
docs: design proposal for POI DOM-ownership re-architecture
ludvigovrevik Jul 23, 2026
aabc3bb
Revert "docs: design proposal for POI DOM-ownership re-architecture"
ludvigovrevik Jul 24, 2026
7956c7c
Revert "chore: fail wrapper generation loudly on Windows path-separat…
ludvigovrevik Jul 24, 2026
d0d5cfd
chore(test): refresh linux visual baselines for poi grouping changes …
ludvigovrevik Jul 24, 2026
229f9cb
fix(ar): reset detection state and data when omitted on later frames
ludvigovrevik Jul 24, 2026
b96e943
perf(ar): compute media projection once per detection sync pass
ludvigovrevik Jul 24, 2026
cc61922
feat(ar)!: forward poi headers through a real slot chain
ludvigovrevik Jul 24, 2026
36ba3b9
feat(ar)!: render stack selection via projection instead of re-parenting
ludvigovrevik Jul 24, 2026
87ffa34
feat(ar)!: render auto-groups as layer shadow chrome via manual slot …
ludvigovrevik Jul 24, 2026
3247e98
docs(ar): declare framework-safe DOM ownership in controller notes
ludvigovrevik Jul 24, 2026
25c16a0
test(ar): pin poi snapshots to the steady rasterization state
ludvigovrevik Jul 24, 2026
0d4b183
fix(ar): stop rescheduling removal of exiting auto-groups
ludvigovrevik Jul 24, 2026
ada9e31
chore(test): take detection-variants baseline from CI render
ludvigovrevik Jul 24, 2026
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
101 changes: 101 additions & 0 deletions docs/proposals/poi-dom-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Proposal: POI components must stop mutating consumer DOM

Status: draft for design review · Owner: AR/POI component family

## Problem

Three mechanisms in the POI family physically mutate light DOM the
consumer owns:

1. **Auto-grouping** — `obc-poi-layer` creates `obc-poi-group` elements
in the consumer's light DOM and re-parents slotted targets into and
out of them (`updateGrouping`).
2. **Selection FLIP** — `obc-poi-layer-stack` moves selected targets
between layer elements (`moveTargetIntoSelectedLayer`,
`animateTargetReturnToOrigin`).
3. **Header relocation** — `PoiBase`/`obc-poi` move `slot="header"`
children into the inner button via MutationObserver because no real
`<slot name="header">` chain exists.

Declarative renderers (React, Vue, Lit templates) assume they own their
children; when nodes move, reconciliation throws (`removeChild` on a
node that is no longer a child) or silently rebuilds state. The
reference AR application pays ~290 lines for this: an imperative POI
registry, an error boundary that exists only to swallow the library's
DOM exceptions, and a parallel unused declarative implementation.

## Goals

- Consumer-owned DOM is never re-parented, created into, or removed by
the library.
- Grouping, selection FLIP animation, and header rendering keep their
current visual behavior.
- A staged migration: additive first, breaking cleanup second.

## Options considered

### A. Shadow mirrors
Layers render internal mirror elements for every slotted target and
hide the originals. Rejected: duplicates every target subtree, breaks
event targets and a11y, doubles rendering cost.

### B. Manual slot assignment (recommended end-state)
Give the stack a shadow root with `slotAssignment: 'manual'`. POI
targets become direct children of the stack; each layer exposes a
`<slot>` in the stack's shadow tree; the stack calls `slot.assign()`
to place targets — including moving a selected target into the
selected layer's slot — **without touching light DOM**. Auto-groups
become shadow-internal chrome: the group frame renders inside the
layer's shadow root, and member targets are `assign()`ed to a
group-positioned slot.

Consequences:
- API change: targets are declared as stack children with a
`layer="<name>"` attribute instead of nested inside layer elements
(constraint of manual assignment: only host children can be
assigned).
- FLIP measurement is unaffected (geometry comes from assigned
rendering position, not tree position).
- Framework-owned children become fully supported; the imperative
registry pattern becomes unnecessary.

### C. Controller-owned generation as the only dynamic path (shipped)
The `detections` API now supports variants, icons, state, and data
rows, making the controller path expressive enough for real
applications; the controller owns every element it creates, so
framework reconciliation never sees them. Slotted POI children remain
supported for static/imperative scenarios and are documented as such
(Framework Notes in controller/stack docs).

## Recommendation

- **Now (this release line):** Option C — already implemented — plus
the documentation warnings. Consumers get a fully supported
framework-safe path immediately.
- **Next major:** Option B for slotted usage. Prototype the manual
slot assignment in the stack first (selection FLIP), then move
grouping chrome into layer shadow, then replace header relocation
with a real `<slot name="header">` chain through
`PoiBase → obc-poi → obc-poi-button`.

## Migration sketch (Option B)

1. `obc-poi-layer-stack` gains `slotAssignment: 'manual'` shadow root
and named layer slots; accepts targets as direct children with
`layer` attributes. Old nesting keeps working via a compatibility
pass that treats layer-nested targets as before (deprecated).
2. `obc-poi-layer` grouping renders group chrome in its shadow root;
`obc-poi-group` stays for manual grouping but auto-groups no longer
appear in light DOM.
3. Remove header MutationObserver relocation in favor of slot
forwarding.
4. Delete the Framework Notes warnings once slotted usage is safe.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## Test plan

- Existing visual stories must render identically through each stage.
- New regression story: framework-managed children (a Lit `repeat`
re-rendering targets every frame) with selection + grouping active —
fails today, must pass after stage 1/2.
- The reference AR app swaps its imperative registry for plain
declarative rendering as the acceptance test.
46 changes: 46 additions & 0 deletions packages/openbridge-webcomponents/fix-generated.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,45 @@
const fs = require('fs');
const path = require('path');

// `lit labs gen` builds import specifiers with the OS path separator. On
// Windows the backslashes are consumed as string escapes when the
// generated source is written (`\b` even becomes a backspace character),
// producing unrecoverable specifiers like
// "@oicl/openbridge-webcomponents/distarpoi-cardpoi-card.js". The
// corruption cannot be repaired after the fact, so detect it and abort
// with actionable guidance instead of letting a broken build land.
function assertNoCorruptedImports(directory) {
const root = path.resolve(__dirname, '..', directory, 'src');
if (!fs.existsSync(root)) return;
const offenders = [];
const visit = (dir) => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(full);
} else if (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) {
const source = fs.readFileSync(full, 'utf8');
if (/@oicl\/openbridge-webcomponents\/dist[^/'"\s]/.test(source)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Detect whitespace control-character corruption too.

Line 22 excludes \s, so a generated specifier containing a materialized \n, \t, \r, or \f after dist can evade this guard and leave a broken wrapper import in the build output. Add a regression fixture for these cases while continuing to allow valid dist/... imports.

Suggested fix
-        if (/@oicl\/openbridge-webcomponents\/dist[^/'"\s]/.test(source)) {
+        if (
+          /@oicl\/openbridge-webcomponents\/dist(?:[^/'"\s]|[\u0000-\u001f])/.test(
+            source
+          )
+        ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (/@oicl\/openbridge-webcomponents\/dist[^/'"\s]/.test(source)) {
if (
/@oicl\/openbridge-webcomponents\/dist(?:[^/'"\s]|[\u0000-\u001f])/.test(
source
)
) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/openbridge-webcomponents/fix-generated.cjs` at line 22, Update the
source-pattern check in fix-generated.cjs to detect materialized whitespace
control characters after the dist prefix, including newline, tab, carriage
return, and form feed, while still allowing valid dist/... imports. Add
regression fixtures covering each corrupted specifier and the valid import case.

offenders.push(full);
}
}
}
};
visit(root);
if (offenders.length > 0) {
throw new Error(
`Wrapper generation produced corrupted import paths (Windows path-separator escaping) in ${directory}:\n` +
offenders
.slice(0, 5)
.map((f) => ` ${f}`)
.join('\n') +
(offenders.length > 5
? `\n ...and ${offenders.length - 5} more`
: '') +
'\nRun "npm run wrappers" on Linux/macOS (or WSL/devcontainer) instead.'
);
}
}

function fixPackageJson(packageName, directory) {
const packageJsonPath = `../${directory}/package.json`;
Expand Down Expand Up @@ -63,6 +104,11 @@ function fixPackageJson(packageName, directory) {
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
}

assertNoCorruptedImports('openbridge-webcomponents-vue');
assertNoCorruptedImports('openbridge-webcomponents-react');
assertNoCorruptedImports('openbridge-webcomponents-ng');
assertNoCorruptedImports('openbridge-webcomponents-svelte');

fixPackageJson('vue', 'openbridge-webcomponents-vue');
fixPackageJson('react', 'openbridge-webcomponents-react');
fixPackageJson('angular', 'openbridge-webcomponents-ng');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,7 @@ export interface ObcPoiButtonDataItem {
* - `data` (default `[]`): when non-empty, renders value/label/unit rows.
* - `hasHeader` + `header` slot: renders a header area and syncs slotted `obc-poi-header` state.
* - `hasRelation` (default `false`): enables optional relation content in data mode.
* - `inExpandedGroup` (default `false`): applies `expanded` CSS class.
* - TODO(designer): Confirm intended UX semantics for `inExpandedGroup` beyond styling.
* - `inExpandedGroup` (default `false`): expanded-group presentation state, applied by the owning group composition during expand/collapse orchestration; consumers should not set it directly.
*
* ## Usage Guidelines
* - Use `layout="anchored"` for standalone marker buttons.
Expand Down Expand Up @@ -151,6 +150,11 @@ export class ObcPoiButton extends LitElement {
@property({type: Boolean})
overlapOpaque = false;
@property({type: String}) type = ObcPoiButtonType.Button;
/**
* Expanded-group presentation state, applied by the owning group composition
* while the button is shown inside an expanded `obc-poi-group` and during
* its collapse animations. Consumers should not set it directly.
*/
@property({type: Boolean}) inExpandedGroup = false;
@property({type: Array, attribute: false}) data: ObcPoiButtonDataItem[] = [];
@property({type: Boolean}) hasRelation = false;
Expand Down
17 changes: 16 additions & 1 deletion packages/openbridge-webcomponents/src/ar/poi-card/poi-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export enum PointerDirection {
*
* ## Events
* - `card-click`: Fired when the card surface is activated in interactive mode.
* - `close-click`: Fired when the header close button is pressed (`hasCloseButton` with the detailed header variant).
*
* ## CSS Parts
* - `wrapper`: Outer layout wrapper holding the pointer arrows and the card surface.
* - `content-box`: Card surface with background, border, and rounded corners.
* - `header`: The `obc-poi-card-header` element, rendered when `showHeader` is true.
* - `content`: Body area wrapping the default slot.
*
* ## Best Practices
* - Keep `index` stable so event payloads remain predictable.
Expand All @@ -76,7 +83,12 @@ export enum PointerDirection {
* @slot - Main card body content.
* @slot leading-icon - Optional icon used by the regular header variant.
* @slot poi-icon - Optional icon used by the detailed header variant.
* @csspart wrapper - Outer layout wrapper holding the pointer arrows and the card surface.
* @csspart content-box - Card surface with background, border, and rounded corners.
* @csspart header - The `obc-poi-card-header` element, rendered when `showHeader` is true.
* @csspart content - Body area wrapping the default slot.
* @fires card-click {CustomEvent<PoiCardClickDetail>} Fired when the card is activated in interactive mode.
* @fires close-click {CustomEvent<void>} Fired when the header close button is pressed (`hasCloseButton` with the detailed header variant). Dispatched by the nested `obc-poi-card-header`; bubbles and is composed, so it re-targets to this element.
* @experimental
*/
@customElement('obc-poi-card')
Expand Down Expand Up @@ -233,6 +245,7 @@ export class ObcPoiCard extends LitElement {
if (!this.showHeader) return nothing;
return html`
<obc-poi-card-header
part="header"
variant=${this.headerVariant}
index=${this.index}
cardTitle=${this.cardTitle}
Expand Down Expand Up @@ -263,6 +276,7 @@ export class ObcPoiCard extends LitElement {
override render() {
return html`
<div
part="wrapper"
class=${classMap({
wrapper: true,
'hug-content': !this.fixedSize,
Expand All @@ -274,6 +288,7 @@ export class ObcPoiCard extends LitElement {
<div class="center">
${this.renderLeftPointer()}
<div
part="content-box"
class="content-box"
role=${this.interactive ? 'button' : nothing}
tabindex=${this.interactive ? '0' : nothing}
Expand All @@ -283,7 +298,7 @@ export class ObcPoiCard extends LitElement {
@keyup=${this.handleKeyUp}
>
${this.renderHeader()}
<div class="content">
<div part="content" class="content">
<slot></slot>
</div>
${this.renderAlertLayer()}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {Meta, StoryObj} from '@storybook/web-components-vite';
import {html} from 'lit';
import './poi-controller.js';
import {PoiFitMode} from './poi-controller.js';
import {PoiDetectionVariant, PoiFitMode} from './poi-controller.js';
import {PoiLayerSelectionMode} from '../poi-layer-stack/poi-layer-stack.js';
import '../poi/poi-data.js';
import '../poi/poi-aton.js';
Expand Down Expand Up @@ -500,3 +500,70 @@ export const BottomLayerWithValues: Story = {
}
},
};

export const DetectionVariants: Story = {
args: {},
render: (args) => {
const detections = [
{
x: 420,
y: 1180,
box_width: 46,
box_height: 38,
id: 'vessel-1',
variant: PoiDetectionVariant.Vessel,
icon: 'obi-vessel-type-psv-outlined',
heading: 45,
data: [{label: 'SPD', value: '9.2', unit: 'kts'}],
},
{
x: 900,
y: 1150,
box_width: 30,
box_height: 40,
id: 'aton-1',
variant: PoiDetectionVariant.Aton,
icon: 'obi-beacon-general-east',
},
{
x: 1400,
y: 1210,
box_width: 40,
box_height: 36,
id: 'data-1',
},
];

return html`
<style>
.variants-stage {
width: 100%;
max-width: 1200px;
aspect-ratio: 16 / 9;
}

.variants-stage img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
<div class="variants-stage">
<obc-poi-controller .detections=${detections} .fit=${args.fit}>
<img slot="media" src="/assets/AR-test-image.png" />
<obc-poi-layer-stack
slot="stack"
selection-mode=${PoiLayerSelectionMode.Single}
>
<obc-poi-layer .isSelected=${true}></obc-poi-layer>
<obc-poi-layer data-controller-layer="background"></obc-poi-layer>
</obc-poi-layer-stack>
</obc-poi-controller>
</div>
`;
},
play: async ({canvasElement}) => {
await waitForStorySettle(canvasElement);
},
};
Loading
Loading