Wall of People Groups: nothing leaks onto the host page (no Font Awesome, no window.d3)
The critic fact-check (CRITIC-9) found the Wall injecting Font Awesome 6.5.1 into the HOST
page's <head> (global .fa-* rules that can restyle a partner's own icons) and loading d3@7 (a
floating range, no SRI) as window.d3, overwriting a host's own d3.
- The card's heart / comment / share icons are now plain SVG paths in the card itself, not
Font Awesome <i> tags in three foreignObject/xhtml divs per card (~6,300 on the wall: lighter
too). The dialog hearts are inline SVG; the source-picker info icons are a text glyph. No
Font Awesome stylesheet is injected anywhere.
- src/wallD3.js loads d3@7.9.0 exactly, with integrity + crossorigin, keeps it in the module
(wallD3()) and puts the host's window.d3 back (or leaves none).
- The heart used `self.$store`; `self` is the browser's window, so a prayer made in this
session never lit it. It now reads this.$store.
Headless proof: a stubbed-DOM test of the d3 loader, 6/6 (the host's d3 restored, none leaked,
exact version + integrity + crossorigin, a failure rejects). The bundle builds and contains 0 Font
Awesome references.
needs eyes: the Wall: hearts, comments and share icons render on the cards; a card whose group has
prayers shows a red heart; a host page with its own d3 or Font Awesome is unaffected.
(cherry picked from commit 28575d6013)
This commit is contained in:
parent
05ab8b9da1
commit
9844f72eaf
5 changed files with 88 additions and 117 deletions
|
|
@ -34,6 +34,7 @@
|
|||
|
||||
<script>
|
||||
import { lodPlan } from '../wallLod.js'
|
||||
import { wallD3 } from '../wallD3.js'
|
||||
// Wall of People Groups - D3 Zoomable Whiteboard
|
||||
export default {
|
||||
name: 'PeopleGroupsWall',
|
||||
|
|
@ -98,7 +99,7 @@ export default {
|
|||
this.canvasHeight = 4000; // 4ft tall
|
||||
}
|
||||
|
||||
const container = d3.select(this.$refs.canvas);
|
||||
const container = wallD3().select(this.$refs.canvas);
|
||||
|
||||
// Create SVG
|
||||
this.svg = container
|
||||
|
|
@ -109,7 +110,7 @@ export default {
|
|||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Create zoom behavior - start zoomed out to see everything
|
||||
this.zoom = d3.zoom()
|
||||
this.zoom = wallD3().zoom()
|
||||
.scaleExtent([0.1, 20])
|
||||
.filter(function(event) {
|
||||
// Allow zoom/pan except when trying to select text
|
||||
|
|
@ -234,7 +235,7 @@ export default {
|
|||
|
||||
// Add people group name - NO TRUNCATION, smaller font, with clickable link
|
||||
cardGroups.each(function(d) {
|
||||
const group = d3.select(this);
|
||||
const group = wallD3().select(this);
|
||||
const peopleID3 = d.peopleID3 || d.PeopleID3;
|
||||
const name = d.name || d.post_title || 'Unknown Group';
|
||||
|
||||
|
|
@ -327,7 +328,7 @@ export default {
|
|||
// href only for cards in view when zoomed in, and takes it out again when they leave.
|
||||
const imgNodes = [];
|
||||
cardGroups.each(function(d, i) {
|
||||
const group = d3.select(this);
|
||||
const group = wallD3().select(this);
|
||||
|
||||
if (d.imageUrl) {
|
||||
// Add SVG image element with proper aspect ratio
|
||||
|
|
@ -369,86 +370,40 @@ export default {
|
|||
const totalButtonWidth = (buttonSize * 3) + (buttonSpacing * 2);
|
||||
const startX = (cardWidth - totalButtonWidth) / 2; // Center the button group
|
||||
|
||||
// Heart button (using foreignObject for FontAwesome)
|
||||
const heartButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openPrayerDialog(d);
|
||||
});
|
||||
|
||||
heartButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html(function(d) {
|
||||
// Combine API prayer count with any local session prayers
|
||||
const apiCount = Number(d.peoplePraying || 0);
|
||||
const localCount = self.$store?.prayers?.[d.ID] || 0;
|
||||
const prayerCount = apiCount + localCount;
|
||||
const iconClass = prayerCount > 0 ? 'fas fa-heart' : 'far fa-heart';
|
||||
const iconColor = prayerCount > 0 ? '#ed4956' : '#262626';
|
||||
return '<i class="' + iconClass + '" style="font-size: ' + buttonSize + 'px; color: ' + iconColor + ';"></i>';
|
||||
});
|
||||
|
||||
// Comment button
|
||||
const commentButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + buttonSpacing)
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.openComments(d);
|
||||
});
|
||||
|
||||
commentButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-comment" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// Share button
|
||||
const shareButton = cardGroups
|
||||
.append('foreignObject')
|
||||
.attr('x', startX + (buttonSpacing * 2))
|
||||
.attr('y', buttonY)
|
||||
.attr('width', buttonSize)
|
||||
.attr('height', buttonSize)
|
||||
.style('cursor', 'pointer')
|
||||
.style('overflow', 'visible')
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
alert('Sharing functionality coming later!');
|
||||
});
|
||||
|
||||
shareButton
|
||||
.append('xhtml:div')
|
||||
.attr('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
.style('width', '100%')
|
||||
.style('height', '100%')
|
||||
.style('display', 'flex')
|
||||
.style('align-items', 'center')
|
||||
.style('justify-content', 'center')
|
||||
.html('<i class="far fa-paper-plane" style="font-size: ' + buttonSize + 'px; color: #262626;"></i>');
|
||||
|
||||
// ICONS ARE PLAIN SVG, drawn in the card itself (CRITIC-9, the critic fact-check). They used to be
|
||||
// Font Awesome <i> tags inside three foreignObject/xhtml divs per card: ~6,300 foreignObjects on
|
||||
// the wall, and a Font Awesome stylesheet injected into the HOST page's <head>. A <g> with a
|
||||
// transparent hit area and one <path> per icon needs neither.
|
||||
const ICON = {
|
||||
heart: 'M12 20.5s-7.2-4.4-9.2-8.8C1.3 8.3 3.2 4.6 6.7 4.6c2 0 3.5 1.1 4.3 2.4.8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.4 3.7 3.9 7.1-2 4.4-9.2 8.8-9.2 8.8z',
|
||||
comment: 'M4 4.5h16v11.5H9.5L4 20.5z',
|
||||
plane: 'M21 3.5 3 10.8l7 2.7 2.7 7 8.3-17z'
|
||||
};
|
||||
const iconScale = buttonSize / 24;
|
||||
const iconButton = (dx, onClick) => {
|
||||
const g = cardGroups
|
||||
.append('g')
|
||||
.attr('class', 'wall-icon-btn')
|
||||
.attr('transform', `translate(${startX + dx}, ${buttonY})`)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', onClick);
|
||||
g.append('rect').attr('width', buttonSize).attr('height', buttonSize).attr('fill', 'transparent');
|
||||
return g.append('path').attr('transform', `scale(${iconScale})`).attr('stroke-width', 1.8)
|
||||
.attr('stroke-linejoin', 'round').attr('stroke-linecap', 'round');
|
||||
};
|
||||
// Heart: filled red once anyone has prayed, outlined otherwise. (The old code read `self.$store`;
|
||||
// `self` is the browser's window, so a prayer made in this session never lit the heart.)
|
||||
iconButton(0, (event, d) => { event.stopPropagation(); this.openPrayerDialog(d); })
|
||||
.attr('d', ICON.heart)
|
||||
.attr('fill', (d) => (Number(d.peoplePraying || 0) + (this.$store?.prayers?.[d.ID] || 0)) > 0 ? '#ed4956' : 'none')
|
||||
.attr('stroke', (d) => (Number(d.peoplePraying || 0) + (this.$store?.prayers?.[d.ID] || 0)) > 0 ? '#ed4956' : '#262626');
|
||||
// Comment
|
||||
iconButton(buttonSpacing, (event, d) => { event.stopPropagation(); this.openComments(d); })
|
||||
.attr('d', ICON.comment).attr('fill', 'none').attr('stroke', '#262626');
|
||||
// Share
|
||||
iconButton(buttonSpacing * 2, (event) => { event.stopPropagation(); alert('Sharing functionality coming later!'); })
|
||||
.attr('d', ICON.plane).attr('fill', 'none').attr('stroke', '#262626');
|
||||
|
||||
// Add prayer count text
|
||||
cardGroups
|
||||
.append('text')
|
||||
|
|
@ -512,14 +467,14 @@ export default {
|
|||
resetZoom() {
|
||||
this.svg.transition()
|
||||
.duration(750)
|
||||
.call(this.zoom.transform, d3.zoomIdentity);
|
||||
.call(this.zoom.transform, wallD3().zoomIdentity);
|
||||
},
|
||||
|
||||
toggleOrientation() {
|
||||
this.isLandscape = !this.isLandscape;
|
||||
|
||||
// Clear existing canvas
|
||||
d3.select(this.$refs.canvas).selectAll('*').remove();
|
||||
wallD3().select(this.$refs.canvas).selectAll('*').remove();
|
||||
|
||||
// Reinitialize with new orientation
|
||||
this.initializeCanvas();
|
||||
|
|
@ -542,7 +497,7 @@ export default {
|
|||
updateLod() {
|
||||
if (!this._lodGrid || !this.svg || !this._imgNodes || this._printing) return;
|
||||
const el = this.svg.node();
|
||||
const t = this._lodT || d3.zoomTransform(el);
|
||||
const t = this._lodT || wallD3().zoomTransform(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
// viewBox + 'meet': one wall unit is this many CSS pixels at zoom 1
|
||||
const displayScale = Math.min(r.width / this.canvasWidth, r.height / this.canvasHeight) || 0;
|
||||
|
|
|
|||
|
|
@ -28,14 +28,14 @@
|
|||
<div v-if="!hasPrayed" class="prayer-prompt">
|
||||
<h3>{{ $t('wall.prayingNow') }}</h3>
|
||||
<button @click="confirmPrayer" class="prayer-confirm-btn">
|
||||
<i class="far fa-heart"></i>
|
||||
<svg class="heart-icon" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.2-4.4-9.2-8.8C1.3 8.3 3.2 4.6 6.7 4.6c2 0 3.5 1.1 4.3 2.4.8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.4 3.7 3.9 7.1-2 4.4-9.2 8.8-9.2 8.8z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
|
||||
{{ $t('wall.yesPraying') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="prayer-confirmed">
|
||||
<div class="confirmed-message">
|
||||
<i class="fas fa-heart" style="color: #ed4956;"></i>
|
||||
<svg class="heart-icon" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.2-4.4-9.2-8.8C1.3 8.3 3.2 4.6 6.7 4.6c2 0 3.5 1.1 4.3 2.4.8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.4 3.7 3.9 7.1-2 4.4-9.2 8.8-9.2 8.8z" fill="#ed4956" stroke="#ed4956" stroke-width="1.8" stroke-linejoin="round"/></svg>
|
||||
<span>{{ $t('wall.thankYou') }}</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@
|
|||
<code>{{ currentSource.url }}</code>
|
||||
</p>
|
||||
<p v-if="dataSource === 'CSV'" class="csv-info">
|
||||
<i class="fas fa-info-circle"></i> CSV includes ~1,439 groups with images from Joshua Project
|
||||
<span aria-hidden="true">ⓘ</span> CSV includes ~1,439 groups with images from Joshua Project
|
||||
</p>
|
||||
<p v-if="dataSource === 'DOXA_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> WordPress API — descriptions & photos, no prayer data
|
||||
<span aria-hidden="true">ⓘ</span> WordPress API — descriptions & photos, no prayer data
|
||||
</p>
|
||||
<p v-if="dataSource === 'PRAY_API'" class="api-info">
|
||||
<i class="fas fa-info-circle"></i> Live prayer data — people praying, committed workers, adoption, images
|
||||
<span aria-hidden="true">ⓘ</span> Live prayer data — people praying, committed workers, adoption, images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
40
plugin-designs/wall-of-people-groups/src/wallD3.js
Normal file
40
plugin-designs/wall-of-people-groups/src/wallD3.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* wallD3.js — the Wall's d3, pinned, integrity-checked and kept OFF the host page's window.
|
||||
*
|
||||
* CRITIC-9 / CRITIC-5 (the critic fact-check): the Wall loaded `d3@7` (a floating range, no SRI)
|
||||
* as a script tag, so it (a) could change underneath every partner without notice and (b) became
|
||||
* `window.d3`, overwriting a host page's own d3, or, if the host already had one, the Wall ran on
|
||||
* whatever version the host shipped. Now: one exact version, `integrity` + `crossorigin`, and the
|
||||
* library is captured into this module and the host's `window.d3` put back exactly as it was.
|
||||
*/
|
||||
const D3_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js'
|
||||
const D3_SRI = 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i'
|
||||
|
||||
let lib = null
|
||||
let loading = null
|
||||
|
||||
/** The Wall's own d3 (null until ensureD3() has resolved). */
|
||||
export function wallD3() { return lib }
|
||||
|
||||
export function ensureD3() {
|
||||
if (lib) return Promise.resolve(lib)
|
||||
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
|
||||
if (!loading) loading = new Promise((resolve, reject) => {
|
||||
const hadOwn = Object.prototype.hasOwnProperty.call(window, 'd3')
|
||||
const hostD3 = window.d3
|
||||
const s = document.createElement('script')
|
||||
s.src = D3_URL
|
||||
s.integrity = D3_SRI
|
||||
s.crossOrigin = 'anonymous'
|
||||
s.onload = () => {
|
||||
lib = window.d3
|
||||
// give the host its own d3 back (or leave no d3 behind if it had none)
|
||||
if (hadOwn) window.d3 = hostD3
|
||||
else { try { delete window.d3 } catch (_) { window.d3 = undefined } }
|
||||
resolve(lib)
|
||||
}
|
||||
s.onerror = () => { loading = null; reject(new Error('d3 failed to load (network or integrity)')) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
|
@ -20,30 +20,7 @@ import PeopleGroupsWall from './src/components/PeopleGroupsWall.vue'
|
|||
import CommentsDialog from './src/components/CommentsDialog.vue'
|
||||
import PrayerDialog from './src/components/PrayerDialog.vue'
|
||||
import SourceSelector from './src/components/SourceSelector.vue'
|
||||
|
||||
const D3_JS = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'
|
||||
const FA_CSS = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
|
||||
let d3Promise = null
|
||||
function ensureD3() {
|
||||
if (typeof window === 'undefined' || window.d3) return Promise.resolve()
|
||||
if (!d3Promise) d3Promise = new Promise((res, rej) => {
|
||||
const s = document.createElement('script'); s.src = D3_JS
|
||||
s.onload = () => res(); s.onerror = () => { d3Promise = null; rej(new Error('d3 failed to load')) }
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
return d3Promise
|
||||
}
|
||||
// Font Awesome's @font-face only registers from the DOCUMENT; its classes must also be inside the
|
||||
// shadow root. So the stylesheet goes in both places.
|
||||
function ensureFontAwesome(root) {
|
||||
if (typeof document === 'undefined') return
|
||||
if (!document.head.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; document.head.appendChild(l)
|
||||
}
|
||||
if (root && root !== document && !root.querySelector(`link[href="${FA_CSS}"]`)) {
|
||||
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FA_CSS; root.prepend(l)
|
||||
}
|
||||
}
|
||||
import { ensureD3 } from './src/wallD3.js'
|
||||
|
||||
export default {
|
||||
name: 'WallOfPeopleGroups',
|
||||
|
|
@ -52,7 +29,6 @@ export default {
|
|||
return { ready: false, error: null, commentsDialogVisible: false, prayerDialogVisible: false, selectedGroup: null }
|
||||
},
|
||||
mounted() {
|
||||
ensureFontAwesome(this.$el?.getRootNode?.())
|
||||
ensureD3().then(() => { this.ready = true }, (e) => { this.error = e.message })
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue