card harness dynamic changes

This commit is contained in:
2026-09-11 21:19:08 -07:00
parent 8fcda5f2b7
commit 56f010a71e
25 changed files with 1055 additions and 126 deletions

View File

@@ -0,0 +1,60 @@
export interface CardAsset {
name: string
artwork: string
mask: string
revision: string
width: number
height: number
}
export interface CardCatalog {
schemaVersion: 1
cards: CardAsset[]
errors: string[]
}
function isCardAsset(value: unknown): value is CardAsset {
if (typeof value !== 'object' || value === null) return false
const card = value as Record<string, unknown>
return typeof card.name === 'string' && card.name.length > 0 &&
typeof card.artwork === 'string' && card.artwork.startsWith('/card-art/') &&
typeof card.mask === 'string' && card.mask.startsWith('/card-art/') &&
typeof card.revision === 'string' &&
Number.isInteger(card.width) && Number.isInteger(card.height)
}
export async function fetchCardCatalog(): Promise<CardCatalog> {
const urls = import.meta.env.DEV
? ['/__card_catalog', '/card-art/manifest.json']
: ['/card-art/manifest.json']
const failures: string[] = []
for (const url of urls) {
try {
const response = await fetch(url, { cache: 'no-store' })
if (!response.ok) throw new Error(`request failed (${response.status})`)
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error(`expected JSON but received ${contentType || 'an unknown content type'}`)
}
return validateCardCatalog(await response.json())
} catch (error) {
failures.push(`${url}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(`Card catalog unavailable: ${failures.join('; ')}`)
}
function validateCardCatalog(value: unknown): CardCatalog {
if (typeof value !== 'object' || value === null) throw new Error('Card catalog is not an object')
const catalog = value as Record<string, unknown>
if (
catalog.schemaVersion !== 1 ||
!Array.isArray(catalog.cards) ||
!catalog.cards.every(isCardAsset) ||
!Array.isArray(catalog.errors) ||
!catalog.errors.every(error => typeof error === 'string')
) {
throw new Error('Card catalog has an unsupported or invalid format')
}
return catalog as unknown as CardCatalog
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three'
import { createPackTexture, defaultPackStyle, packStyles, type PackStyle } from './packDesigns'
const halfWidth = 1.68
const bottom = -2.24
@@ -7,50 +8,6 @@ const top = 2.28
const midDepth = -0.09
const smooth = THREE.MathUtils.smoothstep
function wrapperTexture(back = false) {
const canvas = document.createElement('canvas')
canvas.width = 1024
canvas.height = 1400
const context = canvas.getContext('2d')!
context.fillStyle = '#263a40'
context.fillRect(0, 0, 1024, 1400)
const gradient = context.createLinearGradient(0, 0, 1024, 1400)
gradient.addColorStop(0, '#ffffff0c')
gradient.addColorStop(0.45, '#ffffff00')
gradient.addColorStop(1, '#00000022')
context.fillStyle = gradient
context.fillRect(0, 0, 1024, 1400)
context.strokeStyle = '#a59569'
context.lineWidth = 2
context.strokeRect(66, 155, 892, 1100)
context.strokeRect(80, 169, 864, 1072)
context.fillStyle = '#baa775'
context.fillRect(0, 0, 1024, 100)
context.fillRect(0, 1315, 1024, 85)
context.textAlign = 'center'
context.fillStyle = '#283339'
context.font = 'bold 19px sans-serif'
context.fillText('P U L L T O O P E N →', 512, 58)
context.fillStyle = '#d8c994'
context.save()
context.translate(512, 480)
context.rotate(Math.PI / 4)
context.strokeRect(-100, -100, 200, 200)
context.strokeRect(-80, -80, 160, 160)
context.restore()
context.font = '56px Georgia'
context.fillText('S', 512, 500)
context.font = '44px Georgia'
context.fillText('SANCTIFICATION', 512, 740)
context.font = '21px sans-serif'
context.fillText('C O L L E C T O R S E R I E S', 512, 795)
context.font = '19px sans-serif'
context.fillText(back ? 'AUTHORED EDITION / 001' : 'THREE CARDS / ONE COLLECTION', 512, 1175)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
return texture
}
/**
* Two joined pouch surfaces and one sealed ribbon, never a label over a shell.
* All deformation is evaluated from immutable UVs, not accumulated frame deltas.
@@ -58,13 +15,37 @@ function wrapperTexture(back = false) {
export function createFoilWrapper() {
const root = new THREE.Group()
root.name = 'PACK_FOIL_WRAPPER'
const frontTexture = wrapperTexture()
const designs = new Map<PackStyle, { front: THREE.CanvasTexture; back: THREE.CanvasTexture }>()
const textures: THREE.Texture[] = []
try {
for (const style of packStyles) {
const front = createPackTexture(style)
textures.push(front)
const back = createPackTexture(style, true)
textures.push(back)
designs.set(style, { front, back })
}
} catch (error) {
for (const texture of textures) texture.dispose()
throw error
}
let activeStyle = defaultPackStyle
const initialDesign = designs.get(activeStyle)!
const frontMaterial = new THREE.MeshStandardMaterial({
map: frontTexture, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
map: initialDesign.front, roughness: 0.43, metalness: 0.68, side: THREE.DoubleSide,
})
const backMaterial = frontMaterial.clone()
backMaterial.map = wrapperTexture(true)
backMaterial.map = initialDesign.back
const stripMaterial = frontMaterial.clone()
function setStyle(style: PackStyle) {
const design = designs.get(style)
if (!design) throw new Error(`Unknown pack style: ${style}`)
if (style === activeStyle) return
frontMaterial.map = design.front
backMaterial.map = design.back
stripMaterial.map = design.front
activeStyle = style
}
const sheets = [1, -1].map((side) => {
const geometry = new THREE.PlaneGeometry(halfWidth * 2, seam - bottom, 64, 80)
const mesh = new THREE.Mesh(geometry, side === 1 ? frontMaterial : backMaterial)
@@ -176,9 +157,8 @@ export function createFoilWrapper() {
function dispose() {
for (const { mesh } of sheets) mesh.geometry.dispose()
stripGeometry.dispose()
frontTexture.dispose()
backMaterial.map!.dispose()
for (const texture of textures) texture.dispose()
for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose()
}
return { root, deform, dispose }
return { root, deform, dispose, setStyle, textures: Object.freeze(textures), get style() { return activeStyle } }
}

View File

@@ -12,9 +12,11 @@ import {
type SubstrateName,
} from './cardMaterial'
import { createCardGeometry } from './cardGeometry'
import { PackOpening, type PackFixture } from './packOpening'
import { PackOpening, packSize, rollPackContents } from './packOpening'
import { defaultPackStyle, packStyles } from './packDesigns'
import { fetchCardCatalog, type CardAsset, type CardCatalog } from './cardCatalog'
type FixtureName = PackFixture
type FixtureName = string
type AppMode = 'Inspect' | 'Lab' | 'Pack'
type ManipulationTarget = 'Card' | 'Camera'
type InspectionPose = 'Free' | 'Front' | 'Grazing' | 'Edge' | 'Back'
@@ -50,16 +52,10 @@ interface ExperimentSnapshot {
orbitTarget: [number, number, number]
}
const fixtureAssets: Record<FixtureName, { artwork: string; mask: string }> = {
David: {
artwork: '/reference/david-front.png',
mask: '/reference/david-finish-mask.png',
},
Timothy: {
artwork: '/reference/timothy-front.png',
mask: '/reference/timothy-finish-mask.png',
},
}
let cardCatalog = await fetchCardCatalog()
if (!cardCatalog.cards.length) throw new Error('The card-art folder contains no valid artwork/mask pairs')
let fixtureAssets = new Map(cardCatalog.cards.map(card => [card.name, card]))
let defaultFixture = fixtureAssets.has('David') ? 'David' : cardCatalog.cards[0].name
function configureFrontTexture(texture: THREE.Texture, colorSpace: THREE.ColorSpace) {
texture.colorSpace = colorSpace
@@ -93,12 +89,16 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<option>Pack</option>
</select>
</label>
<label>Card
<select id="fixture">
<option>David</option>
<option>Timothy</option>
<label class="pack-style">Pack style
<select id="pack-style" title="Change the wrapper only; use Restart to compare sealed packs" disabled>
${packStyles.map(style => `<option${style === defaultPackStyle ? ' selected' : ''}>${style}</option>`).join('')}
</select>
</label>
<label>Card
<span class="catalog-state" id="catalog-state" aria-live="polite"></span>
<input id="fixture" list="fixture-options" autocomplete="off" spellcheck="false">
<datalist id="fixture-options"></datalist>
</label>
<label>Finish
<select id="finish">
<option>Holographic</option>
@@ -110,7 +110,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<select id="substrate">
<option>Paper</option>
<option>Linen</option>
<option>Plastic</option>
<option>Metal</option>
<option>Wood</option>
</select>
@@ -159,7 +158,7 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<span id="pack-status" role="status" aria-live="polite"></span>
<progress id="pack-tear" max="1" value="0" aria-label="Top seam tear progress"></progress>
<div class="pack-actions">
<button id="pack-restart" type="button">Restart</button>
<button id="pack-restart" type="button">New pack</button>
<button id="pack-primary" type="button">Open pack</button>
<button id="pack-skip" type="button">Skip</button>
</div>
@@ -184,7 +183,9 @@ const hintElement = document.querySelector<HTMLDivElement>('#hint')!
const topbar = document.querySelector<HTMLElement>('.topbar')!
const controlsToggle = document.querySelector<HTMLButtonElement>('#controls-toggle')!
const modeSelect = document.querySelector<HTMLSelectElement>('#mode')!
const fixtureSelect = document.querySelector<HTMLSelectElement>('#fixture')!
const fixtureSelect = document.querySelector<HTMLInputElement>('#fixture')!
const fixtureOptions = document.querySelector<HTMLDataListElement>('#fixture-options')!
const catalogStateElement = document.querySelector<HTMLSpanElement>('#catalog-state')!
const finishSelect = document.querySelector<HTMLSelectElement>('#finish')!
const substrateSelect = document.querySelector<HTMLSelectElement>('#substrate')!
const poseSelect = document.querySelector<HTMLSelectElement>('#pose')!
@@ -202,6 +203,23 @@ const packTearProgress = document.querySelector<HTMLProgressElement>('#pack-tear
const packPrimaryButton = document.querySelector<HTMLButtonElement>('#pack-primary')!
const packRestartButton = document.querySelector<HTMLButtonElement>('#pack-restart')!
const packSkipButton = document.querySelector<HTMLButtonElement>('#pack-skip')!
const packStyleSelect = document.querySelector<HTMLSelectElement>('#pack-style')!
function renderCardCatalog(catalog: CardCatalog) {
fixtureOptions.replaceChildren(...catalog.cards.map(card => {
const option = document.createElement('option')
option.value = card.name
return option
}))
catalogStateElement.textContent = catalog.errors.length
? `${catalog.cards.length} cards · ${catalog.errors.length} errors`
: `${catalog.cards.length} cards`
catalogStateElement.classList.toggle('has-errors', catalog.errors.length > 0)
catalogStateElement.title = catalog.errors.join('\n')
}
renderCardCatalog(cardCatalog)
fixtureSelect.value = defaultFixture
function setControlsCollapsed(collapsed: boolean) {
topbar.classList.toggle('controls-collapsed', collapsed)
@@ -270,8 +288,9 @@ const backTexture = await loader.loadAsync('/reference/card-back.png')
backTexture.colorSpace = THREE.SRGBColorSpace
backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
const initialAsset = fixtureAssets.get(defaultFixture)!
const initialArtwork = await loader.loadAsync(initialAsset.artwork)
const initialMask = await loader.loadAsync(initialAsset.mask)
configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
configureFrontTexture(initialMask, THREE.NoColorSpace)
@@ -413,7 +432,7 @@ surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).na
updateMaterial()
updateStatus()
})
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
pauseScriptedMotion()
substrateSelect.value = value
updateMaterial()
@@ -515,7 +534,7 @@ const assetActions = {
loadMask: () => maskFileInput.click(),
restoreFixture: () => {
pauseScriptedMotion()
void loadFixture(fixtureSelect.value as FixtureName)
void loadFixture(fixtureSelect.value)
},
}
const assetFolder = gui.addFolder('Assets')
@@ -546,7 +565,7 @@ gui.hide()
let mode: AppMode = 'Inspect'
let textureRequest = 0
let activeFixture: FixtureName = 'David'
let activeFixture: FixtureName = defaultFixture
let customArtworkName: string | undefined
let customMaskName: string | undefined
let dragging = false
@@ -567,6 +586,8 @@ let pack: PackOpening | undefined
let packLoading = false
let packUIDirty = false
let packError: string | undefined
let packTextures: THREE.Texture[] = []
let selectedPackStyle = defaultPackStyle
function updateMaterial() {
applyMaterialControls(frontMaterial, controls)
@@ -696,12 +717,16 @@ function updatePackUI() {
setIfChanged(packTearProgress, 'value', pack?.tearProgress ?? 0)
setIfChanged(packPrimaryButton, 'textContent', view?.action ?? (packError ? 'Retry loading' : 'Loading…'))
setIfChanged(packPrimaryButton, 'disabled', packLoading || pack?.state === 'complete')
setIfChanged(packRestartButton, 'disabled', !pack)
setIfChanged(packRestartButton, 'disabled', packLoading || fixtureAssets.size === 0)
setIfChanged(packSkipButton, 'disabled', !pack || pack.state === 'complete')
setIfChanged(packRestartButton, 'textContent', 'New pack')
setIfChanged(packStyleSelect, 'disabled', !pack || packLoading)
setIfChanged(packStyleSelect, 'value', pack?.style ?? defaultPackStyle)
setIfChanged(packSkipButton, 'title', 'Finish this motion or move to the next resting state')
const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
setIfChanged(packStatusElement, 'textContent', status)
setIfChanged(statusElement, 'textContent', 'Foil tear proof · 3 authored cards · no random rewards')
setIfChanged(statusElement, 'textContent',
`Random foil pack · ${packSize} cards · equal card / material / finish odds`)
setIfChanged(hintElement, 'textContent', view?.hint ?? 'Local reference artwork · approved runtime card materials')
}
@@ -709,8 +734,8 @@ function setIfChanged<T, K extends keyof T>(target: T, key: K, value: T[K]) {
if (target[key] !== value) target[key] = value
}
async function preparePack() {
if (pack) {
async function preparePack(newRoll = false) {
if (pack && !newRoll) {
pack.syncLighting(frontMaterial)
pack.setActive(true)
updatePackUI()
@@ -719,13 +744,26 @@ async function preparePack() {
if (packLoading) return
packLoading = true
packError = undefined
if (newRoll && pack) {
selectedPackStyle = pack.style
pack.dispose()
pack = undefined
for (const texture of packTextures) texture.dispose()
packTextures = []
}
updatePackUI()
let preparedPack: PackOpening | undefined
let textures: THREE.Texture[] = []
try {
// Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
const contents = rollPackContents([...fixtureAssets.keys()])
const selectedNames = [...new Set(contents.map(spec => spec.fixture))]
const selectedAssets = selectedNames.map(name => {
const asset = fixtureAssets.get(name)
if (!asset) throw new Error(`Card "${name}" disappeared from the catalog while rolling`)
return asset
})
// Only cards selected for this pack are loaded; a large catalog does not consume GPU memory.
const paths = selectedAssets.flatMap(asset => [asset.artwork, asset.mask])
const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
@@ -739,18 +777,21 @@ async function preparePack() {
configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
return result.value
})
const selectedTextures = new Map(selectedAssets.map((asset, index) => [
asset.name,
{ artwork: textures[index * 2], mask: textures[index * 2 + 1] },
]))
preparedPack = new PackOpening({
canvas,
textures: {
David: { artwork: textures[0], mask: textures[1] },
Timothy: { artwork: textures[2], mask: textures[3] },
},
contents,
textures: selectedTextures,
backMaterial,
normalMap,
environment: activeEnvironment,
lightPosition,
onChange: () => { packUIDirty = true },
})
preparedPack.setStyle(selectedPackStyle)
preparedPack.resize(canvas.clientWidth, canvas.clientHeight)
let preparedEnvironment: THREE.Texture | null
let preparedLightType: LightType
@@ -765,6 +806,7 @@ async function preparePack() {
preparedPack.setActive(mode === 'Pack')
scene.add(preparedPack.root)
pack = preparedPack
packTextures = textures
} catch (error) {
preparedPack?.dispose()
for (const texture of textures) texture.dispose()
@@ -789,7 +831,7 @@ function isVectorTuple(value: unknown): value is [number, number, number] {
function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
if (typeof value !== 'object' || value === null) return false
const snapshot = value as Record<string, unknown>
const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy'
const fixtureValid = typeof snapshot.fixture === 'string' && snapshot.fixture.length > 0
const finishValid =
snapshot.finish === 'Printed ink' ||
snapshot.finish === 'Foil' ||
@@ -986,6 +1028,9 @@ function captureExperimentPng() {
async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
pauseScriptedMotion()
if (!fixtureAssets.has(snapshot.fixture)) {
throw new Error(`Experiment card "${snapshot.fixture}" is not in the card-art catalog`)
}
fixtureSelect.value = snapshot.fixture
await loadFixture(snapshot.fixture)
controls.finish = snapshot.finish
@@ -1072,7 +1117,8 @@ function applyInspectionPose(pose: InspectionPose) {
function resetView() {
pauseScriptedMotion()
const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
const resetFixture = activeFixture !== defaultFixture ||
customArtworkName !== undefined || customMaskName !== undefined
rotationTarget.set(-0.06, 0.12, 0)
cardRoot.rotation.copy(rotationTarget)
camera.position.set(0, 0, 8.2)
@@ -1080,7 +1126,7 @@ function resetView() {
orbit.update()
modeSelect.value = 'Inspect'
controls.target = 'Card'
fixtureSelect.value = 'David'
fixtureSelect.value = defaultFixture
finishSelect.value = 'Holographic'
substrateSelect.value = 'Paper'
poseSelect.value = 'Front'
@@ -1096,7 +1142,7 @@ function resetView() {
updateEdgeMaterial()
updateMode()
if (resetFixture) {
void loadFixture('David')
void loadFixture(defaultFixture)
} else {
updateStatus()
}
@@ -1107,7 +1153,13 @@ async function loadFixture(nextFixture: FixtureName) {
const request = ++textureRequest
loadingElement.hidden = false
statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
const asset = fixtureAssets[nextFixture]
const asset = fixtureAssets.get(nextFixture)
if (!asset) {
loadingElement.hidden = true
fixtureSelect.value = activeFixture
statusElement.textContent = `Card "${nextFixture}" is not in the card-art catalog.`
return
}
try {
const results = await Promise.allSettled([
@@ -1119,6 +1171,7 @@ async function loadFixture(nextFixture: FixtureName) {
if (maskResult.status === 'fulfilled') maskResult.value.dispose()
throw artworkResult.reason
}
if (maskResult.status === 'rejected') {
artworkResult.value.dispose()
throw maskResult.reason
@@ -1157,6 +1210,45 @@ async function loadFixture(nextFixture: FixtureName) {
}
}
function resolveFixtureName(value: string) {
if (fixtureAssets.has(value)) return value
const normalized = value.toLocaleLowerCase()
return [...fixtureAssets.keys()].find(name => name.toLocaleLowerCase() === normalized)
}
async function refreshCardCatalog() {
try {
const nextCatalog = await fetchCardCatalog()
if (!nextCatalog.cards.length) {
throw new Error(nextCatalog.errors.join('; ') || 'No valid artwork/mask pairs found')
}
const previousAsset: CardAsset | undefined = fixtureAssets.get(activeFixture)
const nextAssets = new Map(nextCatalog.cards.map(card => [card.name, card]))
const nextAsset = nextAssets.get(activeFixture)
cardCatalog = nextCatalog
fixtureAssets = nextAssets
defaultFixture = fixtureAssets.has('David') ? 'David' : nextCatalog.cards[0].name
renderCardCatalog(nextCatalog)
if (nextCatalog.errors.length) {
statusElement.textContent = `Card catalog: ${nextCatalog.errors.join('; ')}`
}
if (
nextAsset &&
previousAsset?.revision !== nextAsset.revision &&
customArtworkName === undefined &&
customMaskName === undefined
) {
await loadFixture(activeFixture)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
catalogStateElement.textContent = 'Catalog error'
catalogStateElement.classList.add('has-errors')
catalogStateElement.title = message
statusElement.textContent = `Could not refresh card catalog: ${message}`
}
}
async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
const request = ++textureRequest
const objectUrl = URL.createObjectURL(file)
@@ -1331,17 +1423,46 @@ packPrimaryButton.addEventListener('click', () => {
if (pack) pack.primary()
else void preparePack()
})
packRestartButton.addEventListener('click', () => pack?.restart())
packRestartButton.addEventListener('click', () => { void preparePack(true) })
packSkipButton.addEventListener('click', () => pack?.skip())
packStyleSelect.addEventListener('change', () => {
try {
const style = packStyles.find(style => style === packStyleSelect.value)
if (!style || !pack) throw new Error('The selected pack style is unavailable')
selectedPackStyle = style
pack.setStyle(style)
updatePackUI()
} catch (error) {
console.error('Could not change pack style', error)
packStyleSelect.value = pack?.style ?? defaultPackStyle
packStatusElement.textContent = `Pack style unavailable: ${error instanceof Error ? error.message : String(error)}`
}
})
modeSelect.addEventListener('change', () => {
pauseScriptedMotion()
updateMode()
})
fixtureSelect.addEventListener('change', () => {
function selectFixtureFromPicker() {
const fixture = resolveFixtureName(fixtureSelect.value.trim())
if (!fixture) {
statusElement.textContent = `No card named "${fixtureSelect.value.trim()}" exists in the card-art folder.`
fixtureSelect.value = activeFixture
return
}
fixtureSelect.value = fixture
pauseScriptedMotion()
void loadFixture(fixtureSelect.value as FixtureName)
void loadFixture(fixture)
}
fixtureSelect.addEventListener('change', selectFixtureFromPicker)
fixtureSelect.addEventListener('focus', () => { void refreshCardCatalog() })
fixtureSelect.addEventListener('keydown', event => {
if (event.key === 'Enter') selectFixtureFromPicker()
})
window.addEventListener('focus', () => { void refreshCardCatalog() })
if (import.meta.hot) {
import.meta.hot.on('card-catalog:update', () => { void refreshCardCatalog() })
}
substrateSelect.addEventListener('change', () => {
pauseScriptedMotion()
controls.substrate = substrateSelect.value as SubstrateName

View File

@@ -0,0 +1,365 @@
import * as THREE from 'three'
export const packStyles = ['Cathedral glass', 'Illuminated manuscript', 'Quiet modern'] as const
export type PackStyle = typeof packStyles[number]
export const defaultPackStyle: PackStyle = 'Quiet modern'
type Ink = CanvasRenderingContext2D
type Point = readonly [number, number]
const width = 1024
const height = 1400
const gold = '#c7a365'
const serif = 'Georgia, "Times New Roman", serif'
const sans = 'Arial, Helvetica, sans-serif'
function line(ctx: Ink, points: readonly Point[], color: string, weight = 2) {
ctx.beginPath()
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
ctx.strokeStyle = color
ctx.lineWidth = weight
ctx.stroke()
}
function polygon(ctx: Ink, points: readonly Point[], fill: string, stroke?: string, weight = 3) {
ctx.beginPath()
points.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))
ctx.closePath()
ctx.fillStyle = fill
ctx.fill()
if (stroke) {
ctx.strokeStyle = stroke
ctx.lineWidth = weight
ctx.stroke()
}
}
function circle(ctx: Ink, x: number, y: number, radius: number, color: string, weight = 2) {
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.strokeStyle = color
ctx.lineWidth = weight
ctx.stroke()
}
// Measure tracking explicitly so every label stays inside its designated print area.
function text(ctx: Ink, label: string, x: number, y: number, size: number,
color: string, family = sans, tracking = 0, maxWidth = 860, weight = 'normal') {
ctx.save()
ctx.font = `${weight} ${size}px ${family}`
ctx.fillStyle = color
ctx.textBaseline = 'middle'
ctx.textAlign = 'left'
const letters = Array.from(label)
const measured = letters.reduce((sum, letter) => sum + ctx.measureText(letter).width, 0)
+ Math.max(0, letters.length - 1) * tracking
const scale = Math.min(1, maxWidth / measured)
ctx.translate(x, y)
ctx.scale(scale, 1)
let cursor = -measured / 2
for (const letter of letters) {
ctx.fillText(letter, cursor, 0)
cursor += ctx.measureText(letter).width + tracking
}
ctx.restore()
}
function cross(ctx: Ink, x: number, y: number, size: number, color: string, weight = 3) {
line(ctx, [[x, y - size], [x, y + size]], color, weight)
line(ctx, [[x - size * 0.64, y - size * 0.3], [x + size * 0.64, y - size * 0.3]], color, weight)
}
function diamond(ctx: Ink, x: number, y: number, size: number, color: string) {
polygon(ctx, [[x, y - size], [x + size, y], [x, y + size], [x - size, y]], color)
}
function footer(ctx: Ink, back: boolean, color: string, y = 1195) {
text(ctx, back ? 'AUTHORED EDITION / 001' : 'ONE COLLECTION',
512, y, 22, color, sans, 3, 790)
}
function pointedWindow(ctx: Ink, x: number, top: number, w: number, h: number) {
ctx.beginPath()
ctx.moveTo(x - w / 2, top + h)
ctx.lineTo(x - w / 2, top + h * 0.43)
ctx.bezierCurveTo(x - w / 2, top + h * 0.2, x - w * 0.17, top + h * 0.06, x, top)
ctx.bezierCurveTo(x + w * 0.17, top + h * 0.06, x + w / 2, top + h * 0.2, x + w / 2, top + h * 0.43)
ctx.lineTo(x + w / 2, top + h)
ctx.closePath()
}
function glass(ctx: Ink, x: number, top: number, w: number, h: number) {
const lead = '#0b1b36'
const pane = ['#204e77', '#347690', '#23385e', '#435a83', '#749597', '#315977']
ctx.save()
pointedWindow(ctx, x, top, w, h)
ctx.clip()
ctx.fillStyle = '#173858'
ctx.fillRect(x - w / 2, top, w, h)
const centerY = top + h * 0.35
const radius = w * 0.33
// Radiating glass tesserae surround a twelve-petal rose and three lancets.
for (let i = 0; i < 24; i++) {
const a = i * Math.PI / 12 - Math.PI / 2
const b = (i + 1) * Math.PI / 12 - Math.PI / 2
polygon(ctx, [
[x + Math.cos(a) * radius, centerY + Math.sin(a) * radius],
[x + Math.cos(a) * w * 1.6, centerY + Math.sin(a) * w * 1.6],
[x + Math.cos(b) * w * 1.6, centerY + Math.sin(b) * w * 1.6],
[x + Math.cos(b) * radius, centerY + Math.sin(b) * radius],
], pane[i % pane.length]!, lead, 6)
}
circle(ctx, x, centerY, radius + 7, gold, 3)
for (let i = 0; i < 12; i++) {
ctx.save()
ctx.translate(x, centerY)
ctx.rotate(i * Math.PI / 6)
ctx.beginPath()
ctx.moveTo(0, -radius)
ctx.quadraticCurveTo(radius * 0.4, -radius * 0.52, 0, -radius * 0.2)
ctx.quadraticCurveTo(-radius * 0.4, -radius * 0.52, 0, -radius)
ctx.fillStyle = pane[(i + 1) % pane.length]!
ctx.fill()
ctx.strokeStyle = lead
ctx.lineWidth = 5
ctx.stroke()
ctx.restore()
}
circle(ctx, x, centerY, radius * 0.21, gold, 3)
cross(ctx, x, centerY, radius * 0.13, '#e1cf99', 4)
const lancetTop = top + h * 0.62
for (let i = -1; i <= 1; i++) {
const lx = x + i * w * 0.27
const ly = lancetTop + Math.abs(i) * 26
const lw = w * 0.21
const lh = top + h - ly - 20
pointedWindow(ctx, lx, ly, lw, lh)
ctx.fillStyle = i === 0 ? '#346b82' : '#1c3c61'
ctx.fill()
ctx.strokeStyle = lead
ctx.lineWidth = 8
ctx.stroke()
pointedWindow(ctx, lx, ly + 10, lw - 14, lh - 17)
ctx.strokeStyle = gold
ctx.lineWidth = 2
ctx.stroke()
for (let y = ly + 66; y < top + h - 45; y += 67) {
polygon(ctx, [[lx, y - 24], [lx + lw * 0.3, y], [lx, y + 24], [lx - lw * 0.3, y]],
i === 0 ? '#90a49c' : '#416c88', lead, 4)
}
}
ctx.restore()
pointedWindow(ctx, x, top, w, h)
ctx.strokeStyle = gold
ctx.lineWidth = 5
ctx.stroke()
pointedWindow(ctx, x, top - 17, w + 30, h + 30)
ctx.lineWidth = 2
ctx.stroke()
}
function cathedral(ctx: Ink, back: boolean) {
const ivory = '#e6ddc5'
ctx.fillStyle = '#101e3a'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#344767'
ctx.lineWidth = 2
ctx.strokeRect(77, 165, 870, 1080)
for (const x of [92, 932]) {
line(ctx, [[x, 185], [x, 1225]], gold, 1.5)
for (const y of [190, 1220]) diamond(ctx, x, y, 5, gold)
}
text(ctx, 'SANCTIFICATION', 512, 231, back ? 48 : 61, ivory, serif, 1.2, 814)
text(ctx, 'COLLECTOR SERIES', 512, 288, 21, gold, sans, 5)
if (back) {
glass(ctx, 512, 435, 392, 575)
line(ctx, [[217, 680], [217, 1040], [402, 1040]], '#42577a')
line(ctx, [[807, 680], [807, 1040], [622, 1040]], '#42577a')
diamond(ctx, 217, 648, 7, gold)
diamond(ctx, 807, 648, 7, gold)
} else {
glass(ctx, 512, 358, 626, 688)
}
line(ctx, [[260, 1127], [468, 1127]], gold, 1.5)
diamond(ctx, 512, 1127, 7, gold)
line(ctx, [[556, 1127], [764, 1127]], gold, 1.5)
footer(ctx, back, ivory)
}
function leaf(ctx: Ink, x: number, y: number, angle: number, size: number, fill: string, ink: string) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(angle)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.bezierCurveTo(-size * 0.55, -size * 0.3, -size * 0.4, -size * 0.85, 0, -size)
ctx.bezierCurveTo(size * 0.52, -size * 0.62, size * 0.4, -size * 0.24, 0, 0)
ctx.fillStyle = fill
ctx.fill()
ctx.strokeStyle = ink
ctx.lineWidth = 1.7
ctx.stroke()
line(ctx, [[0, 0], [0, -size * 0.78]], ink, 1.3)
ctx.restore()
}
function vine(ctx: Ink, x: number, top: number, length: number, mirror: number) {
const ink = '#796840'
ctx.save()
ctx.translate(x, top)
ctx.scale(mirror, 1)
ctx.beginPath()
ctx.moveTo(0, 0)
for (let y = 0; y < length; y += 120) {
ctx.bezierCurveTo(-28, y + 34, 28, y + 86, 0, Math.min(y + 120, length))
}
ctx.strokeStyle = ink
ctx.lineWidth = 3
ctx.stroke()
for (let y = 38; y < length - 24; y += 60) {
const direction = Math.floor(y / 60) % 2 === 0 ? -1 : 1
leaf(ctx, 0, y, direction * 0.95, 38, '#b5af88', ink)
leaf(ctx, 0, y + 18, -direction * 0.85, 27, '#d5c7a0', ink)
if (y % 180 === 38) {
const bx = direction * 32
line(ctx, [[0, y], [bx, y - 19]], ink, 1.5)
for (const [dx, dy] of [[-6, -3], [6, -3], [0, -13]]) {
ctx.beginPath()
ctx.arc(bx + dx!, y - 19 + dy!, 5, 0, Math.PI * 2)
ctx.fillStyle = '#853e45'
ctx.fill()
}
}
}
ctx.restore()
}
function manuscript(ctx: Ink, back: boolean) {
const burgundy = '#672b39'
const ink = '#493a30'
ctx.fillStyle = '#eee3c9'
ctx.fillRect(0, 0, width, height)
// A regular, fine paper tooth reads as print rather than baked illumination.
ctx.fillStyle = '#ded1b7'
for (let y = 111; y < 1315; y += 9) {
for (let x = (y % 2) * 5; x < width; x += 13) ctx.fillRect(x, y, 1, 1)
}
ctx.strokeStyle = burgundy
ctx.lineWidth = 3
ctx.strokeRect(78, 166, 868, 1072)
ctx.strokeStyle = '#a18b60'
ctx.lineWidth = 1.5
ctx.strokeRect(91, 180, 842, 1044)
vine(ctx, 139, 230, 900, 1)
vine(ctx, 885, 230, 900, -1)
for (const [x, y] of [[105, 194], [919, 194], [105, 1210], [919, 1210]]) {
diamond(ctx, x!, y!, 8, burgundy)
}
text(ctx, 'COLLECTOR SERIES', 512, 254, 21, burgundy, sans, 4, 680)
const emblemY = back ? 562 : 438
ctx.fillStyle = burgundy
ctx.fillRect(448, emblemY - 75, 128, 150)
ctx.strokeStyle = '#c9af72'
ctx.lineWidth = 2
ctx.strokeRect(457, emblemY - 66, 110, 132)
cross(ctx, 512, emblemY, 38, '#eee0b9', 6)
for (const x of [470, 554]) {
leaf(ctx, x, emblemY + 47, x < 512 ? -0.28 : 0.28, 34, '#bea16b', '#d8c397')
}
text(ctx, 'SANCTIFICATION', 512, back ? 746 : 651, back ? 49 : 56,
burgundy, serif, 0.3, 700)
line(ctx, [[222, back ? 805 : 712], [802, back ? 805 : 712]], '#a18b60', 1.5)
if (!back) {
ctx.save()
ctx.translate(512, 976)
for (const direction of [-1, 1]) {
ctx.save()
ctx.scale(direction, 1)
ctx.beginPath()
ctx.moveTo(0, 28)
ctx.bezierCurveTo(70, 22, 142, -58, 162, -150)
ctx.strokeStyle = '#796840'
ctx.lineWidth = 2.5
ctx.stroke()
for (let i = 0; i < 5; i++) {
const x = 36 + i * 26
const y = 16 - i * 27
leaf(ctx, x, y, -0.75, 39, '#b5af88', '#796840')
leaf(ctx, x, y, 0.8, 31, '#d5c7a0', '#796840')
}
ctx.restore()
}
diamond(ctx, 0, 28, 6, burgundy)
ctx.restore()
} else {
diamond(ctx, 512, 954, 8, burgundy)
}
footer(ctx, back, ink, 1174)
}
function modern(ctx: Ink, back: boolean) {
const ivory = '#f0e9d5'
const muted = '#a7b6a0'
ctx.fillStyle = '#123b30'
ctx.fillRect(0, 0, width, height)
// The modern edition uses an editorial grid, not an ornamental frame.
line(ctx, [[104, 184], [920, 184]], '#66816a', 1.5)
text(ctx, 'COLLECTOR SERIES', 512, 232, 21, ivory, sans, 5.5, 800)
const cy = back ? 580 : 465
circle(ctx, 512, cy, 39, gold, 2)
cross(ctx, 512, cy, 23, ivory, 3)
line(ctx, [[512, cy - 56], [512, cy - 49]], gold)
line(ctx, [[512, cy + 49], [512, cy + 56]], gold)
text(ctx, 'SANCTIFICATION', 512, back ? 757 : 684, back ? 51 : 65,
ivory, sans, back ? 1.2 : 0.8, 834, '600')
line(ctx, [[480, back ? 823 : 769], [544, back ? 823 : 769]], gold, 3)
if (!back) {
for (const x of [490, 512, 534]) {
ctx.fillStyle = muted
ctx.fillRect(x - 2, 1040, 4, 4)
}
}
line(ctx, [[104, 1131], [920, 1131]], '#66816a', 1.5)
footer(ctx, back, ivory, 1193)
}
function seams(ctx: Ink) {
ctx.fillStyle = gold
ctx.fillRect(0, 0, width, 100)
ctx.fillRect(0, 1315, width, 85)
ctx.strokeStyle = '#a38450'
ctx.lineWidth = 1
for (let x = 8; x < width; x += 12) {
line(ctx, [[x, 7], [x, 23]], '#a38450', 1)
line(ctx, [[x, 78], [x, 94]], '#a38450', 1)
line(ctx, [[x, 1329], [x, 1386]], '#a38450', 1)
}
line(ctx, [[0, 99], [1024, 99]], '#745c35', 2)
line(ctx, [[0, 1316], [1024, 1316]], '#745c35', 2)
text(ctx, 'PULL TO OPEN', 500, 51, 24, '#252b23', sans, 3.5, 620, 'bold')
line(ctx, [[706, 51], [778, 51], [765, 39]], '#252b23', 3)
line(ctx, [[778, 51], [765, 63]], '#252b23', 3)
}
export function createPackTexture(style: PackStyle, back = false): THREE.CanvasTexture {
if (!packStyles.includes(style)) throw new Error(`Unknown pack style: ${String(style)}`)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d', { alpha: false })
if (!ctx) throw new Error('Cannot create pack texture: Canvas 2D context is unavailable')
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
switch (style) {
case 'Cathedral glass': cathedral(ctx, back); break
case 'Illuminated manuscript': manuscript(ctx, back); break
case 'Quiet modern': modern(ctx, back); break
}
seams(ctx)
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
texture.name = `${style} — ${back ? 'back' : 'front'}`
return texture
}

View File

@@ -1,6 +1,7 @@
import * as THREE from 'three'
import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
import { createFoilWrapper } from './foilWrapper'
import type { PackStyle } from './packDesigns'
import {
applyEdgeMaterialControls,
applyMaterialControls,
@@ -9,19 +10,38 @@ import {
type SubstrateName,
} from './cardMaterial'
export type PackFixture = 'David' | 'Timothy'
export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
| 'revealing' | 'inspecting' | 'advancing' | 'complete'
export const packContents: readonly {
fixture: PackFixture
export interface PackCardSpec {
fixture: string
finish: FinishName
substrate: SubstrateName
}[] = [
{ fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
{ fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
{ fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
]
}
export const packSize = 10
export const packFinishes: readonly FinishName[] = ['Printed ink', 'Foil', 'Holographic']
export const packSubstrates: readonly SubstrateName[] = ['Paper', 'Linen', 'Metal', 'Wood']
function randomChoice<T>(values: readonly T[], random: () => number) {
const roll = random()
if (!Number.isFinite(roll) || roll < 0 || roll >= 1) {
throw new Error(`Random source must return a number in [0, 1); received ${roll}`)
}
return values[Math.floor(roll * values.length)]
}
export function rollPackContents(
fixtures: readonly string[],
random: () => number = Math.random,
): PackCardSpec[] {
if (!fixtures.length) throw new Error('Cannot roll a pack without valid card artwork')
return Array.from({ length: packSize }, () => ({
fixture: randomChoice(fixtures, random),
finish: randomChoice(packFinishes, random),
substrate: randomChoice(packSubstrates, random),
}))
}
// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
const stackDepthSpacing = 0.055
@@ -34,7 +54,8 @@ const inspectionCameraRetreat = inspectionDepth - 0.85
interface PackOptions {
canvas: HTMLCanvasElement
textures: Record<PackFixture, { artwork: THREE.Texture; mask: THREE.Texture }>
contents: readonly PackCardSpec[]
textures: ReadonlyMap<string, { artwork: THREE.Texture; mask: THREE.Texture }>
backMaterial: THREE.Material
normalMap: THREE.Texture
environment: THREE.CubeTexture
@@ -94,6 +115,7 @@ export class PackOpening {
readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
state: PackState = 'sealed'
paused = false
readonly contents: readonly PackCardSpec[]
private readonly options: PackOptions
private readonly wrapper = createFoilWrapper()
private readonly cards
@@ -119,13 +141,16 @@ export class PackOpening {
private pinchDistance: number | undefined
constructor(options: PackOptions) {
if (!options.contents.length) throw new Error('Pack must contain at least one card')
this.options = options
this.contents = options.contents.map(spec => ({ ...spec }))
this.root.name = 'PACK_PROTOTYPE'
this.root.position.y = 0.22
this.root.visible = false
this.root.add(this.wrapper.root)
this.cards = packContents.map((spec) => {
const textures = options.textures[spec.fixture]
this.cards = this.contents.map((spec) => {
const textures = options.textures.get(spec.fixture)
if (!textures) throw new Error(`Missing loaded textures for pack card "${spec.fixture}"`)
const material = createCardMaterial(
textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
)
@@ -144,16 +169,16 @@ export class PackOpening {
get view() {
const number = `${this.index + 1} / ${this.cards.length}`
const spec = packContents[this.index]
const spec = this.contents[this.index]
const views: Record<PackState, { action: string; status: string; hint: string }> = {
sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
sealed: { action: 'Tear open', status: `Sealed · ${this.cards.length} cards`, hint: 'Drag the gold top seam to the right · or use Tear open' },
opening: {
action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
status: this.openingProgress < 0.38
? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
: this.openingProgress < 0.52 ? 'Strip curling away'
: this.openingProgress < 0.62 ? 'Spreading the foil mouth'
: this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
: this.openingProgress < 0.87 ? `Sliding out ${this.cards.length} cards` : 'Settling the face-down stack',
hint: this.openingProgress < 0.38
? 'Pull right along the seam · release to hold · Finish opening resumes'
: 'Touch or wheel pauses motion · Skip finishes this step',
@@ -168,7 +193,7 @@ export class PackOpening {
hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
},
advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' },
complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' },
complete: { action: 'Pack complete', status: `Complete · all ${this.cards.length} cards inspected`, hint: 'Choose New pack for another independent roll' },
}
return this.paused && this.motion
? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
@@ -186,9 +211,16 @@ export class PackOpening {
}
}
get style() { return this.wrapper.style }
setStyle(style: PackStyle) {
this.wrapper.setStyle(style)
this.options.onChange()
}
async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) {
if (this.active) throw new Error('Prepare pack GPU resources before activating the pack')
const textures = new Set<THREE.Texture>()
const textures = new Set<THREE.Texture>(this.wrapper.textures)
if (scene.environment) textures.add(scene.environment)
this.root.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return
@@ -209,7 +241,7 @@ export class PackOpening {
await renderer.compileAsync(this.root, this.camera, scene)
try {
this.cards.forEach((card, index) => {
applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 })
applyEdgeMaterialControls(card.edge, { ...this.contents[index], condition: 1 })
})
await renderer.compileAsync(this.root, this.camera, scene)
} finally {
@@ -311,8 +343,9 @@ export class PackOpening {
this.updateCamera()
this.cards.forEach((card, index) => {
const target = this.stackPose(index)
const stagger = this.cards.length === 1 ? 0 : index / (this.cards.length - 1) * 0.06
// All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling)
target.position.y += 4.4 * smooth(value, 0.62 + stagger, 0.8 + stagger) * (1 - settling)
setPose(card.object, target)
})
const aside = smooth(value, 0.83, 0.87)
@@ -340,9 +373,9 @@ export class PackOpening {
})
if (state === 'inspecting' && !this.revealed.has(this.index)) {
this.revealed.add(this.index)
applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
applyEdgeMaterialControls(this.cards[this.index].edge, { ...this.contents[this.index], condition: 1 })
this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
detail: { index: this.index + 1, ...packContents[this.index] },
detail: { index: this.index + 1, ...this.contents[this.index] },
}))
}
if (state === 'complete' && !this.completed) {

View File

@@ -20,7 +20,8 @@ body,
}
button,
select {
select,
input {
color: inherit;
font: inherit;
}
@@ -108,6 +109,7 @@ h1 {
}
select,
input,
button {
height: 34px;
border: 1px solid rgba(227, 215, 181, 0.2);
@@ -116,7 +118,8 @@ button {
outline: none;
}
select {
select,
input {
width: 100%;
min-width: 0;
padding: 0 28px 0 10px;
@@ -128,17 +131,39 @@ button {
}
button:hover,
select:hover {
select:hover,
input:hover {
border-color: rgba(218, 186, 102, 0.62);
background: #202630;
}
button:focus-visible,
select:focus-visible {
select:focus-visible,
input:focus-visible {
outline: 2px solid #d6b860;
outline-offset: 2px;
}
input {
padding: 0 10px;
}
.catalog-state {
position: absolute;
top: 0;
right: 0;
color: #777f8a;
font-size: 9px;
}
.catalog-state.has-errors {
color: #e59a8d;
}
.toolbar label:has(.catalog-state) {
position: relative;
}
.viewport-shell {
position: relative;
min-height: 0;
@@ -306,12 +331,18 @@ button:disabled {
cursor: default;
}
.pack-mode .toolbar > :not(:first-child) {
.toolbar .pack-style,
.pack-mode .toolbar > :not(:first-child):not(.pack-style) {
display: none;
}
.pack-mode .toolbar .pack-style {
display: grid;
flex-basis: 200px;
}
.pack-mode .toolbar {
flex: 0 1 180px;
flex: 0 1 400px;
}
.pack-mode .hint {