From 0d6f4a9df304d903e4fc755ed2be924c22affcaa Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 14 Dec 2025 09:56:28 -0500 Subject: [PATCH] Improve pattern builder UX and animations --- assets/app.js | 849 ++++++++++++++++++++ assets/app.min.js | 1762 +++++++++++++++++++++++++++++++++++++++++- assets/style.css | 193 ++++- assets/style.min.css | 900 ++++++++++++++++++++- index.html | 164 +++- 5 files changed, 3860 insertions(+), 8 deletions(-) diff --git a/assets/app.js b/assets/app.js index d944139..10db008 100644 --- a/assets/app.js +++ b/assets/app.js @@ -2,6 +2,282 @@ let projects = JSON.parse(localStorage.getItem('crochetCounters')) || []; let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || []; if (!Array.isArray(patterns)) patterns = []; +let saveFlashTimer = null; +const crochetAbbrev = [ + { code: 'alt', desc: 'alternate' }, + { code: 'approx', desc: 'approximately' }, + { code: 'beg', desc: 'begin/beginning' }, + { code: 'bet', desc: 'between' }, + { code: 'bl/blo', desc: 'back loop/back loop only' }, + { code: 'bo', desc: 'bobble' }, + { code: 'bp', desc: 'back post' }, + { code: 'bpdc', desc: 'back post double crochet' }, + { code: 'bpdtr', desc: 'back post double treble crochet' }, + { code: 'bphdc', desc: 'back post half double crochet' }, + { code: 'bpsc', desc: 'back post single crochet' }, + { code: 'bptr', desc: 'back post treble crochet' }, + { code: 'cc', desc: 'contrasting color' }, + { code: 'ch', desc: 'chain stitch' }, + { code: 'ch-sp', desc: 'chain space' }, + { code: 'cl', desc: 'cluster' }, + { code: 'cont', desc: 'continue' }, + { code: 'dc', desc: 'double crochet' }, + { code: 'dc2tog', desc: 'double crochet 2 stitches together' }, + { code: 'dec', desc: 'decrease' }, + { code: 'dtr', desc: 'double treble crochet' }, + { code: 'edc', desc: 'extended double crochet' }, + { code: 'ehdc', desc: 'extended half double crochet' }, + { code: 'esc', desc: 'extended single crochet' }, + { code: 'etr', desc: 'extended treble crochet' }, + { code: 'fl/flo', desc: 'front loop/front loop only' }, + { code: 'foll', desc: 'following' }, + { code: 'fp', desc: 'front post' }, + { code: 'fpdc', desc: 'front post double crochet' }, + { code: 'fpdtr', desc: 'front post double treble crochet' }, + { code: 'fphdc', desc: 'front post half double crochet' }, + { code: 'fpsc', desc: 'front post single crochet' }, + { code: 'fptr', desc: 'front post treble crochet' }, + { code: 'hdc', desc: 'half double crochet' }, + { code: 'hdc2tog', desc: 'half double crochet 2 stitches together' }, + { code: 'inc', desc: 'increase' }, + { code: 'lp', desc: 'loop' }, + { code: 'm', desc: 'marker' }, + { code: 'mc', desc: 'main color' }, + { code: 'pat', desc: 'pattern' }, + { code: 'pc', desc: 'popcorn stitch' }, + { code: 'pm', desc: 'place marker' }, + { code: 'prev', desc: 'previous' }, + { code: 'ps/puff', desc: 'puff stitch' }, + { code: 'rem', desc: 'remaining' }, + { code: 'rep', desc: 'repeat' }, + { code: 'rnd', desc: 'round' }, + { code: 'rs', desc: 'right side' }, + { code: 'sc', desc: 'single crochet' }, + { code: 'sc2tog', desc: 'single crochet 2 stitches together' }, + { code: 'sh', desc: 'shell' }, + { code: 'sk', desc: 'skip' }, + { code: 'sl st', desc: 'slip stitch' }, + { code: 'sm/sl m', desc: 'slip marker' }, + { code: 'sp', desc: 'space' }, + { code: 'st', desc: 'stitch' }, + { code: 'tbl', desc: 'through back loop' }, + { code: 'tch/t-ch', desc: 'turning chain' }, + { code: 'tog', desc: 'together' }, + { code: 'tr', desc: 'treble crochet' }, + { code: 'tr2tog', desc: 'treble crochet 2 stitches together' }, + { code: 'trtr', desc: 'triple treble crochet' }, + { code: 'ws', desc: 'wrong side' }, + { code: 'yo', desc: 'yarn over' }, + { code: 'yoh', desc: 'yarn over hook' }, + // Tunisian + { code: 'etss', desc: 'extended Tunisian simple stitch' }, + { code: 'fwp', desc: 'forward pass' }, + { code: 'retp', desc: 'return pass' }, + { code: 'tdc', desc: 'Tunisian double crochet' }, + { code: 'tfs', desc: 'Tunisian full stitch' }, + { code: 'thdc', desc: 'Tunisian half double crochet' }, + { code: 'tks', desc: 'Tunisian knit stitch' }, + { code: 'tps', desc: 'Tunisian purl stitch' }, + { code: 'trs', desc: 'Tunisian reverse stitch' }, + { code: 'tsc', desc: 'Tunisian single crochet' }, + { code: 'tss', desc: 'Tunisian simple stitch' }, + { code: 'tslst', desc: 'Tunisian slip stitch' }, + { code: 'ttr', desc: 'Tunisian treble crochet' }, + { code: 'ttw', desc: 'Tunisian twisted' } +]; + +const knitAbbrev = [ + { code: 'alt', desc: 'alternate' }, + { code: 'approx', desc: 'approximately' }, + { code: 'beg', desc: 'beginning' }, + { code: 'bet', desc: 'between' }, + { code: 'BO', desc: 'bind off' }, + { code: 'byo', desc: 'backward yarn over' }, + { code: 'CC', desc: 'contrasting color' }, + { code: 'cn', desc: 'cable needle' }, + { code: 'CO', desc: 'cast on' }, + { code: 'cont', desc: 'continue' }, + { code: 'dec', desc: 'decrease' }, + { code: 'dpn', desc: 'double-pointed needles' }, + { code: 'foll', desc: 'follow' }, + { code: 'inc', desc: 'increase' }, + { code: 'k', desc: 'knit' }, + { code: 'k1B', desc: 'knit stitch in row below' }, + { code: 'kfb', desc: 'knit 1 front and back (inc)' }, + { code: 'ksp', desc: 'knit 1, slip back, pass over (dec)' }, + { code: 'k2tog', desc: 'knit 2 together (dec)' }, + { code: 'kwise', desc: 'knitwise' }, + { code: 'LH', desc: 'left hand' }, + { code: 'lp', desc: 'loop' }, + { code: 'm', desc: 'marker' }, + { code: 'M1', desc: 'make one knitwise (inc)' }, + { code: 'M1R', desc: 'make one right (inc)' }, + { code: 'M1L', desc: 'make one left (inc)' }, + { code: 'M1p', desc: 'make one purlwise (inc)' }, + { code: 'M1rp', desc: 'make one right purlwise (inc)' }, + { code: 'M1lp', desc: 'make one left purlwise (inc)' }, + { code: 'MC', desc: 'main color' }, + { code: 'p', desc: 'purl' }, + { code: 'pat', desc: 'pattern' }, + { code: 'pfb', desc: 'purl front and back (inc)' }, + { code: 'pm', desc: 'place marker' }, + { code: 'p2tog', desc: 'purl 2 together (dec)' }, + { code: 'prev', desc: 'previous' }, + { code: 'psso', desc: 'pass slipped stitch over' }, + { code: 'p2sso', desc: 'pass 2 slipped stitches over' }, + { code: 'pwise', desc: 'purlwise' }, + { code: 'rem', desc: 'remaining' }, + { code: 'rep', desc: 'repeat' }, + { code: 'rev St st', desc: 'reverse stockinette stitch' }, + { code: 'RH', desc: 'right hand' }, + { code: 'rnd', desc: 'round' }, + { code: 'RS', desc: 'right side' }, + { code: 'SKP', desc: 'slip 1 k-wise, k1, pass slipped over (dec)' }, + { code: 'SK2P', desc: 'slip 1 k-wise, k2tog, pass slipped over (dec)' }, + { code: 'sl', desc: 'slip' }, + { code: 'sl1k', desc: 'slip 1 knitwise' }, + { code: 'sl1p', desc: 'slip 1 purlwise' }, + { code: 'sl st', desc: 'slip stitch' }, + { code: 'sm', desc: 'slip marker' }, + { code: 'ssk', desc: 'slip 2 k-wise, k through back loops (dec)' }, + { code: 'ssp', desc: 'slip 2 k-wise, return, p through back loops (dec)' }, + { code: 'sssk', desc: 'slip 3 k-wise, k through back loops (double dec)' }, + { code: 'sssp', desc: 'slip 3 k-wise, return, p through back loops (double dec)' }, + { code: 'S2KP2', desc: 'slip 2 as if to k2tog, k1, pass 2 over (double dec)' }, + { code: 'SSPP2', desc: 'slip 2 k-wise, return, p2tog tbl, p1, pass 2 over (double dec)' }, + { code: 'st', desc: 'stitch' }, + { code: 'St st', desc: 'stockinette stitch' }, + { code: 'tbl', desc: 'through back loop' }, + { code: 'tfl', desc: 'through front loop' }, + { code: 'tog', desc: 'together' }, + { code: 'WS', desc: 'wrong side' }, + { code: 'w&t', desc: 'wrap and turn' }, + { code: 'wyib', desc: 'with yarn in back' }, + { code: 'wyif', desc: 'with yarn in front' }, + { code: 'yb', desc: 'yarn back' }, + { code: 'yfwd/yf', desc: 'yarn forward' }, + { code: 'yo', desc: 'yarn over' }, + { code: 'yon', desc: 'yarn over needle' }, + { code: 'yrn', desc: 'yarn round needle' } +]; + +const crochetBuckets = [ + { label: 'Core stitches', codes: ['ch', 'sc', 'hdc', 'dc', 'tr', 'sl st'] }, + { label: 'Shaping & repeats', codes: ['inc', 'dec', 'tog', 'rep', 'rem', 'rnd', 'rs', 'ws', 'tch/t-ch'] }, + { label: 'Placement & loops', codes: ['fl/flo', 'bl/blo', 'tbl', 'lp', 'sp', 'ch-sp', 'prev'] }, + { label: 'Post stitches', codes: ['fp', 'fpdc', 'fptr', 'fpsc', 'fphdc', 'fpdtr', 'bp', 'bpdc', 'bptr', 'bpsc', 'bphdc', 'bpdtr'] }, + { label: 'Extended & special', codes: ['pat', 'pc', 'ps/puff', 'cl', 'sh', 'dtr', 'edc', 'ehdc', 'esc', 'etr', 'tr2tog', 'trtr', 'yo', 'yoh'] }, + { label: 'Markers & color', codes: ['mc', 'cc', 'm', 'pm', 'sm/sl m'] }, + { label: 'Tunisian', codes: ['etss', 'fwp', 'retp', 'tdc', 'tfs', 'thdc', 'tks', 'tps', 'trs', 'tsc', 'tss', 'tslst', 'ttr', 'ttw'] }, + { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont', 'foll', 'pat', 'prev'] } +]; + +const knitBuckets = [ + { label: 'Core stitches', codes: ['k', 'p', 'yo', 'sl st', 'St st', 'rev St st'] }, + { label: 'Increases', codes: ['inc', 'M1', 'M1R', 'M1L', 'M1p', 'M1rp', 'M1lp', 'kfb', 'pfb'] }, + { label: 'Decreases', codes: ['dec', 'k2tog', 'ssk', 'SKP', 'SK2P', 'ssp', 'sssk', 'sssp', 'S2KP2', 'SSPP2', 'p2tog', 'psso', 'p2sso', 'ksp', 'tog'] }, + { label: 'Slips & markers', codes: ['sl', 'sl1k', 'sl1p', 'm', 'pm', 'sm', 'cn', 'dpn'] }, + { label: 'Yarn moves', codes: ['wyib', 'wyif', 'yfwd/yf', 'yb', 'yon', 'yrn', 'w&t'] }, + { label: 'Cast on / bind off', codes: ['CO', 'BO'] }, + { label: 'Color & pattern', codes: ['MC', 'CC', 'pat'] }, + { label: 'Positioning', codes: ['k1B', 'tbl', 'tfl', 'lp', 'prev'] }, + { label: 'Rows & sides', codes: ['rep', 'rem', 'RS', 'WS', 'rnd', 'foll'] }, + { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont'] } +]; + +const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end']; +const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']); + +function getActiveAbbrevLibrary() { + return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev; +} + +function getAbbrevByCode(code) { + return crochetAbbrev.find(a => a.code === code) || knitAbbrev.find(a => a.code === code); +} + +function buildAbbrevGroups(library, buckets) { + const index = new Map(library.map(item => [item.code, item])); + const used = new Set(); + const groups = []; + buckets.forEach(bucket => { + const items = []; + bucket.codes.forEach(code => { + const item = index.get(code); + if (item && !used.has(code)) { + items.push(item); + used.add(code); + } + }); + if (items.length) groups.push({ label: bucket.label, items }); + }); + const leftover = library.filter(item => !used.has(item.code)); + if (leftover.length) groups.push({ label: 'Other', items: leftover }); + return groups; +} + +function getAbbrevGroups() { + const library = getActiveAbbrevLibrary(); + const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; + return buildAbbrevGroups(library, buckets); +} + +function getCoreCodes() { + const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; + const core = buckets.find(b => b.label === 'Core stitches'); + return core ? core.codes : []; +} + +function getPatternButtonCodes() { + const base = (patternDraft.abbrevSelection && patternDraft.abbrevSelection.length) + ? patternDraft.abbrevSelection + : (patternDraft.mode === 'knit' + ? ['k', 'p', 'yo', 'k2tog', 'ssk', 'sl st'] + : ['ch', 'sc', 'hdc', 'dc', 'tr', 'inc', 'dec', 'sl st', 'sk', 'rep']); + const merged = [...base, ...repeatTokens]; + return Array.from(new Set(merged)); +} +function normalizePatternDraft(d = {}) { + const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' }; + const base = { + mode: 'crochet', + currentRow: 1, + line: '', + output: '', + meta: { title: '', designer: '' }, + materials: '', + gauge: '', + gaugeSts: '', + gaugeRows: '', + gaugeHook: '', + size: '', + abbrev: '', + abbrevSelection: [], + stitches: '', + notes: '', + steps: [] + }; + const merged = { ...base, ...d }; + merged.meta = { ...base.meta, ...(d.meta || {}) }; + merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : []; + merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : []; + merged.materials = merged.materials || ''; + merged.gauge = merged.gauge || ''; + merged.gaugeSts = merged.gaugeSts || ''; + merged.gaugeRows = merged.gaugeRows || ''; + merged.gaugeHook = merged.gaugeHook || ''; + merged.size = merged.size || ''; + merged.abbrev = merged.abbrev || ''; + merged.stitches = merged.stitches || ''; + merged.notes = merged.notes || ''; + if (!merged.mode) merged.mode = 'crochet'; + if (!merged.currentRow) merged.currentRow = 1; + if (merged.line === undefined) merged.line = ''; + if (merged.output === undefined) merged.output = ''; + return merged; +} + +let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {}); // New Earthy/Woodland Palette extracted from image vibes const colors = [ '#a17d63', // Soft oak @@ -45,7 +321,570 @@ const saveOverlay = document.getElementById('saveOverlay'); const saveList = document.getElementById('saveList'); const patternPicker = document.getElementById('patternPicker'); const patternSelect = document.getElementById('patternSelect'); +const patternOverlay = document.getElementById('patternOverlay'); +const patternRowNumber = null; +const patternLine = null; +const patternOutput = document.getElementById('patternOutput'); +const patternButtonsWrap = null; if (patternPicker && patternSelect) populatePatternSelect(); +if (patternOverlay) { + patternOverlay.addEventListener('click', (e) => { + if (e.target === patternOverlay) closePatternComposer(); + }); +} +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && patternOverlay && patternOverlay.classList.contains('active')) { + closePatternComposer(); + } + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && patternOverlay && patternOverlay.classList.contains('active')) { + e.preventDefault(); + addStep(); + } +}); + +function persistPatternDraft() { + localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft)); + flashSave(); +} + +function setPatternMode(mode) { + patternDraft.mode = mode; + persistPatternDraft(); + if (!document.querySelector('.pattern-overlay')) return; + document.querySelectorAll('.pattern-mode').forEach(btn => { + btn.classList.toggle('is-active', btn.dataset.mode === mode); + }); + renderAbbrevChecklist(); + renderSteps(); +} + +function loadDefaultAbbrev() { + patternDraft.abbrevSelection = getCoreCodes(); + updateAbbrevFromSelection(); + renderAbbrevChecklist(); +} + +function bindPatternInputs() { + const titleEl = document.getElementById('patternTitle'); + const designerEl = document.getElementById('patternDesigner'); + const materialsEl = document.getElementById('patternMaterials'); + const gaugeEl = document.getElementById('patternGauge'); + const gaugeStsEl = document.getElementById('patternGaugeSts'); + const gaugeRowsEl = document.getElementById('patternGaugeRows'); + const gaugeHookEl = document.getElementById('patternGaugeHook'); + const sizeEl = document.getElementById('patternSize'); + const abbrevEl = document.getElementById('patternAbbrev'); + const stitchesEl = document.getElementById('patternStitches'); + const notesEl = document.getElementById('patternNotes'); + const outputEl = document.getElementById('patternOutput'); + if (titleEl) titleEl.addEventListener('input', e => { updateMetaField('title', e.target.value); }); + if (designerEl) designerEl.addEventListener('input', e => { updateMetaField('designer', e.target.value); }); + if (materialsEl) materialsEl.addEventListener('input', e => { updatePatternField('materials', e.target.value); }); + if (gaugeEl) gaugeEl.addEventListener('input', e => { updatePatternField('gauge', e.target.value); }); + if (gaugeStsEl) gaugeStsEl.addEventListener('input', e => { updatePatternField('gaugeSts', e.target.value); }); + if (gaugeRowsEl) gaugeRowsEl.addEventListener('input', e => { updatePatternField('gaugeRows', e.target.value); }); + if (gaugeHookEl) gaugeHookEl.addEventListener('input', e => { updatePatternField('gaugeHook', e.target.value); }); + if (sizeEl) sizeEl.addEventListener('input', e => { updatePatternField('size', e.target.value); }); + if (abbrevEl) abbrevEl.addEventListener('input', e => { updatePatternField('abbrev', e.target.value); }); + if (stitchesEl) stitchesEl.addEventListener('input', e => { updatePatternField('stitches', e.target.value); }); + if (notesEl) notesEl.addEventListener('input', e => { updatePatternField('notes', e.target.value); }); + if (outputEl) outputEl.addEventListener('input', e => { + patternDraft.output = e.target.value; + const lines = patternDraft.output.split('\n').filter(l => l.trim() !== ''); + patternDraft.currentRow = Math.max(1, lines.length + 1); + persistPatternDraft(); + }); +} + +function syncPatternUI() { + setPatternMode(patternDraft.mode); + const titleEl = document.getElementById('patternTitle'); + const designerEl = document.getElementById('patternDesigner'); + const materialsEl = document.getElementById('patternMaterials'); + const gaugeEl = document.getElementById('patternGauge'); + const gaugeStsEl = document.getElementById('patternGaugeSts'); + const gaugeRowsEl = document.getElementById('patternGaugeRows'); + const gaugeHookEl = document.getElementById('patternGaugeHook'); + const sizeEl = document.getElementById('patternSize'); + const abbrevEl = document.getElementById('patternAbbrev'); + const stitchesEl = document.getElementById('patternStitches'); + const notesEl = document.getElementById('patternNotes'); + if (titleEl) titleEl.value = patternDraft.meta.title; + if (designerEl) designerEl.value = patternDraft.meta.designer; + if (materialsEl) materialsEl.value = patternDraft.materials; + if (gaugeEl) gaugeEl.value = patternDraft.gauge; + if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts; + if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows; + if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook; + if (sizeEl) sizeEl.value = patternDraft.size; + if (abbrevEl) abbrevEl.value = patternDraft.abbrev; + if (stitchesEl) stitchesEl.value = patternDraft.stitches; + if (notesEl) notesEl.value = patternDraft.notes; + renderSteps(); + renderAbbrevChecklist(); +} + +function addPatternToken(tok) { + if (!patternLine) return; + patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok; + patternLine.value = patternDraft.line; + persistPatternDraft(); +} + +function clearPatternLine() { + if (!patternLine) return; + patternDraft.line = ''; + patternLine.value = ''; + persistPatternDraft(); +} + +function addPatternRow() { + if (!patternLine || !patternOutput) return; + const line = patternDraft.line.trim(); + if (!line) return; + const rowLabel = patternDraft.mode === 'knit' ? 'Row' : 'Rnd'; + const rowText = `${rowLabel} ${patternDraft.currentRow}: ${line}`; + patternDraft.output = patternDraft.output ? `${patternDraft.output}\n${rowText}` : rowText; + patternDraft.currentRow += 1; + patternDraft.line = ''; + persistPatternDraft(); + syncPatternUI(); +} + +function removePatternRow() { + if (!patternOutput) return; + const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== ''); + if (lines.length === 0) return; + lines.pop(); + patternDraft.output = lines.join('\n'); + patternDraft.currentRow = Math.max(1, lines.length + 1); + persistPatternDraft(); + syncPatternUI(); +} + +function clearPatternOutput() { + if (!patternOutput) return; + patternDraft.output = ''; + patternDraft.currentRow = 1; + patternDraft.line = ''; + patternDraft.meta = { title: '', designer: '' }; + patternDraft.materials = ''; + patternDraft.gauge = ''; + patternDraft.gaugeSts = ''; + patternDraft.gaugeRows = ''; + patternDraft.gaugeHook = ''; + patternDraft.size = ''; + patternDraft.abbrev = ''; + patternDraft.stitches = ''; + patternDraft.notes = ''; + patternDraft.steps = []; + if (patternOutput) patternOutput.value = ''; + persistPatternDraft(); + syncPatternUI(); +} + +function exportPatternJSON() { + const payload = { patternDraft }; + const name = patternDraft.meta.title || 'pattern'; + const filename = `${name.replace(/\s+/g,'_').toLowerCase()}_draft.json`; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function importPatternJSON() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + if (!data.patternDraft) throw new Error('Invalid file'); + patternDraft = normalizePatternDraft(data.patternDraft); + persistPatternDraft(); + syncPatternUI(); + } catch (err) { + showAlert({ title: 'Import failed', text: err.message }); + } + }; + input.click(); +} + +function exportPatternPDF() { + const w = window.open('', '_blank'); + if (!w) return; + const styles = getComputedStyle(document.body); + const themeBg = styles.getPropertyValue('--bg') || '#f4f0e8'; + const themeText = styles.getPropertyValue('--text') || '#2f2b28'; + const accent = styles.getPropertyValue('--project-color') || '#7a8c6a'; + const gaugeBlock = `${patternDraft.gaugeSts || ''}${patternDraft.gaugeRows ? ' • ' + patternDraft.gaugeRows : ''}${patternDraft.gaugeHook ? ' • ' + patternDraft.gaugeHook : ''}`; + const fontLink = ''; + const html = ` + + + ${patternDraft.meta.title || 'Pattern'} - PDF + ${fontLink} + + + +

${patternDraft.meta.title || 'Pattern'}

+

${patternDraft.meta.designer || ''}

+
+
Materials
+
${patternDraft.materials || ''}
+
+
+
Gauge / Size
+
${gaugeBlock}\n${patternDraft.gauge || ''}\n${patternDraft.size || ''}
+
+
+
Abbreviations
+
${patternDraft.abbrev || ''}
+
+
+
Stitch Guide
+
${patternDraft.stitches || ''}
+
+
+
Steps
+ ${patternDraft.steps.map((s, i) => { + const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('
'); + const note = s.note ? `
${s.note}` : ''; + return `
Step ${i + 1}${s.title ? ': ' + s.title : ''}
${rows}${note}
`; + }).join('
')} +
+
+
Rows
+
${patternDraft.output || ''}
+
+
+
Notes
+
${patternDraft.notes || ''}
+
+ + + `; + w.document.write(html); + w.document.close(); + w.focus(); + w.print(); +} + +function showPatternTab(tab) { + document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab)); + document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab)); +} + +function updateMetaField(field, value) { + patternDraft.meta[field] = value; + persistPatternDraft(); +} + +function updatePatternField(field, value) { + patternDraft[field] = value; + persistPatternDraft(); +} + +function renderSteps() { + const container = document.getElementById('patternSteps'); + if (!container) return; + container.innerHTML = ''; + patternDraft.steps.forEach((step, idx) => { + const card = document.createElement('div'); + card.className = 'pattern-step-card card-pop'; + const rows = step.rows || []; + const rowList = rows.map((r, i) => ` +
+ + + +
+ `).join(''); + card.innerHTML = ` +
Step ${idx + 1}
+ + +
+ ${getPatternButtonCodes().map(tok => ``).join('')} +
+
+ +
+ + +
+
+
${rowList}
+ + + + +
+ + + +
+ `; + card.querySelectorAll('input, textarea').forEach(el => { + el.addEventListener('input', (e) => { + const i = Number(e.target.dataset.idx); + const field = e.target.dataset.field; + if (!patternDraft.steps[i]) return; + patternDraft.steps[i][field] = e.target.value; + persistPatternDraft(); + }); + }); + card.querySelectorAll('.pattern-buttons button').forEach(btn => { + btn.addEventListener('click', () => { + const tok = btn.dataset.tok; + addPatternTokenToStep(idx, tok); + }); + }); + card.querySelectorAll('input[data-row]').forEach(inp => { + inp.addEventListener('input', (e) => { + const rowIdx = Number(e.target.dataset.row); + updateStepRow(idx, rowIdx, e.target.value); + }); + }); + const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]'); + if (rowTextarea) { + rowTextarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + addStepRow(idx); + } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + addStep(); + } + }); + } + requestAnimationFrame(() => { + card.classList.remove('card-pop'); + }); + container.appendChild(card); + }); + const addRow = document.createElement('div'); + addRow.className = 'pattern-step-card add-step-row'; + addRow.innerHTML = ` + + `; + container.appendChild(addRow); +} + +function addStep() { + patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' }); + persistPatternDraft(); + renderSteps(); +} + +function addStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + const line = (step.rowDraft || '').trim(); + if (!line) return; + step.rows.push(line); + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function removeStepLastRow(idx) { + // no-op: last-row removal handled per-row delete +} + +function removeStepRow(idx, rowIdx) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; + step.rows.splice(rowIdx, 1); + persistPatternDraft(); + renderSteps(); +} + +function clearStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function updateStepRow(idx, rowIdx, value) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; + step.rows[rowIdx] = value; + persistPatternDraft(); +} + +function renderAbbrevChecklist() { + const listEl = document.getElementById('patternAbbrevList'); + if (!listEl) return; + listEl.innerHTML = ''; + const selected = new Set(patternDraft.abbrevSelection); + getAbbrevGroups().forEach(bucket => { + const wrap = document.createElement('details'); + wrap.className = 'abbrev-group'; + wrap.open = true; + const summary = document.createElement('summary'); + summary.textContent = `${bucket.label} (${bucket.items.length})`; + wrap.appendChild(summary); + const grid = document.createElement('div'); + grid.className = 'abbrev-grid'; + bucket.items.forEach(item => { + const pill = document.createElement('div'); + pill.className = 'abbrev-pill'; + if (selected.has(item.code)) pill.classList.add('is-selected'); + pill.dataset.code = item.code; + pill.innerHTML = ` + ${item.code} + ${item.desc} + `; + pill.addEventListener('click', () => { + const code = pill.dataset.code; + if (pill.classList.contains('is-selected')) { + pill.classList.remove('is-selected'); + patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code); + } else { + pill.classList.add('is-selected'); + if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code); + } + updateAbbrevFromSelection(); + }); + grid.appendChild(pill); + }); + wrap.appendChild(grid); + listEl.appendChild(wrap); + }); + renderSelectedAbbrev(); +} + +function updateAbbrevFromSelection() { + const lines = patternDraft.abbrevSelection.map(code => { + const found = getAbbrevByCode(code); + return found ? `${found.code} – ${found.desc}` : code; + }); + patternDraft.abbrev = lines.join('\n'); + persistPatternDraft(); + const abbrevEl = document.getElementById('patternAbbrev'); + if (abbrevEl) abbrevEl.value = patternDraft.abbrev; + renderSelectedAbbrev(); + renderSteps(); +} + +function renderSelectedAbbrev() { + renderPatternButtons(); +} + +function renderPatternButtons() { + // Buttons are rendered per-step; no global buttons. +} + +function deleteStep(idx) { + patternDraft.steps.splice(idx, 1); + persistPatternDraft(); + renderSteps(); +} + +function moveStep(idx, dir) { + const target = idx + dir; + if (target < 0 || target >= patternDraft.steps.length) return; + const tmp = patternDraft.steps[idx]; + patternDraft.steps[idx] = patternDraft.steps[target]; + patternDraft.steps[target] = tmp; + persistPatternDraft(); + renderSteps(); +} + +function addPatternTokenToStep(idx, tok) { + const step = patternDraft.steps[idx]; + if (!step) return; + // Append with comma/space + if (!step.rowDraft) { + step.rowDraft = tok; + } else { + // If last token matches, increment count + const parts = step.rowDraft.split(',').map(s => s.trim()).filter(Boolean); + if (parts.length && !nonMergeTokens.has(tok)) { + const last = parts[parts.length - 1]; + const escapedTok = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = last.match(new RegExp(`^${escapedTok}(\\d+)$`, 'i')); + if (last.toLowerCase() === tok.toLowerCase()) { + parts[parts.length - 1] = `${tok}2`; + } else if (match) { + const n = parseInt(match[1], 10) + 1; + parts[parts.length - 1] = `${tok}${n}`; + } else { + parts.push(tok); + } + step.rowDraft = parts.join(', '); + } else { + parts.push(tok); + step.rowDraft = parts.join(', '); + } + } + persistPatternDraft(); + renderSteps(); +} + +function addStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + const line = (step.rowDraft || '').trim(); + if (!line) return; + step.rows.push(line); + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function removeStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || step.rows.length === 0) return; + step.rows.pop(); + persistPatternDraft(); + renderSteps(); +} + +function clearStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function openPatternComposer() { + if (!patternOverlay) return; + patternOverlay.classList.add('active'); + syncPatternUI(); + showPatternTab('steps'); +} + +function closePatternComposer() { + if (!patternOverlay) return; + patternOverlay.classList.remove('active'); +} +syncPatternUI(); +bindPatternInputs(); +renderAbbrevChecklist(); let pendingSaveSelection = []; let lastCountPulse = null; let lastFinishedId = null; @@ -910,3 +1749,13 @@ function render() { } render(); +function flashSave() { + const el = document.getElementById('patternSaveIndicator'); + if (!el) return; + el.textContent = 'Saved'; + el.style.opacity = '1'; + if (saveFlashTimer) clearTimeout(saveFlashTimer); + saveFlashTimer = setTimeout(() => { + el.style.opacity = '0.6'; + }, 1200); +} diff --git a/assets/app.min.js b/assets/app.min.js index 2823c13..10db008 100644 --- a/assets/app.min.js +++ b/assets/app.min.js @@ -1 +1,1761 @@ -let projects=JSON.parse(localStorage.getItem("crochetCounters"))||[];let patterns=JSON.parse(localStorage.getItem("crochetPatterns"))||[];if(!Array.isArray(patterns))patterns=[];const colors=["#a17d63","#7a8c6a","#c7a272","#b88b8a","#7b9189","#aa9a7a","#5f6d57","#b07d6f","#6d5947","#c4b08a","#7c7565","#8ca58c"];const oldColors=["#b56b54","#7a8b4f","#cba052","#5f8a8b","#8c6246","#a87b8c","#4a5d43","#9c7e63"];let modalState={type:null,projectId:null,partId:null};const app=document.getElementById("app");const modal=document.getElementById("modalOverlay");const modalInput=document.getElementById("modalInput");const modalTitle=document.getElementById("modalTitle");const hapticTick=()=>{if("vibrate"in navigator)navigator.vibrate(12)};const installBtn=document.getElementById("installBtn");const importInput=document.getElementById("importFile");const motionBtn=document.getElementById("motionBtn");const colorOverlay=document.getElementById("colorOverlay");const colorGrid=document.getElementById("colorGrid");const customColorInput=document.getElementById("customColorInput");const saveOverlay=document.getElementById("saveOverlay");const saveList=document.getElementById("saveList");const patternPicker=document.getElementById("patternPicker");const patternSelect=document.getElementById("patternSelect");if(patternPicker&&patternSelect)populatePatternSelect();let pendingSaveSelection=[];let lastCountPulse=null;let lastFinishedId=null;let fireflyTimer=null;let fireflyActive=false;let titleClicks=[];let easterEggCooling=false;if("serviceWorker"in navigator){window.addEventListener("load",(()=>{navigator.serviceWorker.register("/sw.js").catch((()=>{}))}))}let deferredInstallPrompt=null;const isStandalone=()=>window.matchMedia("(display-mode: standalone)").matches||window.navigator.standalone===true;function hideInstall(){if(installBtn)installBtn.classList.add("hidden")}function showInstall(){if(installBtn)installBtn.classList.remove("hidden")}window.addEventListener("beforeinstallprompt",(e=>{e.preventDefault();deferredInstallPrompt=e;if(!isStandalone())showInstall()}));window.addEventListener("appinstalled",(()=>{deferredInstallPrompt=null;hideInstall()}));if(installBtn){installBtn.addEventListener("click",(async()=>{if(!deferredInstallPrompt)return;deferredInstallPrompt.prompt();const choice=await deferredInstallPrompt.userChoice;if(choice.outcome==="accepted")hideInstall();deferredInstallPrompt=null}))}if(isStandalone())hideInstall();function removeSwal(){const existing=document.querySelector(".swal-overlay");if(existing)existing.remove()}function showConfirm({title:title="Are you sure?",text:text="",confirmText:confirmText="Yes",cancelText:cancelText="Cancel",danger:danger=false}={}){return new Promise((resolve=>{removeSwal();const overlay=document.createElement("div");overlay.className="swal-overlay";overlay.innerHTML=`\n
\n
${title}
\n
${text}
\n
\n \n \n
\n
\n `;const cancelBtn=overlay.querySelector(".swal-cancel");const confirmBtn=overlay.querySelector(".swal-confirm, .swal-danger");cancelBtn.onclick=()=>{removeSwal();resolve(false)};confirmBtn.onclick=()=>{removeSwal();resolve(true)};overlay.addEventListener("click",(e=>{if(e.target===overlay){removeSwal();resolve(false)}}));document.addEventListener("keydown",(function onKey(e){if(e.key==="Escape"){removeSwal();resolve(false);document.removeEventListener("keydown",onKey)}}));document.body.appendChild(overlay)}))}function showAlert({title:title="Notice",text:text=""}={}){return new Promise((resolve=>{removeSwal();const overlay=document.createElement("div");overlay.className="swal-overlay";overlay.innerHTML=`\n
\n
${title}
\n
${text}
\n
\n \n
\n
\n `;const okBtn=overlay.querySelector(".swal-confirm");okBtn.onclick=()=>{removeSwal();resolve()};overlay.addEventListener("click",(e=>{if(e.target===overlay){removeSwal();resolve()}}));document.addEventListener("keydown",(function onKey(e){if(e.key==="Escape"){removeSwal();resolve();document.removeEventListener("keydown",onKey)}}));document.body.appendChild(overlay)}))}let isDarkMode=JSON.parse(localStorage.getItem("crochetDarkMode"));if(isDarkMode===null){if(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches){isDarkMode=true}else{isDarkMode=false}}let animationsEnabled=JSON.parse(localStorage.getItem("crochetAnimations"));if(animationsEnabled===null)animationsEnabled=true;function applyTheme(){if(isDarkMode){document.body.classList.add("dark-mode");document.getElementById("themeBtn").innerHTML=''}else{document.body.classList.remove("dark-mode");document.getElementById("themeBtn").innerHTML=''}handleAmbientDrift();updateMotionBtn()}function toggleTheme(){isDarkMode=!isDarkMode;localStorage.setItem("crochetDarkMode",isDarkMode);applyTheme();if(animationsEnabled){document.body.classList.add("theme-animating");setTimeout((()=>document.body.classList.remove("theme-animating")),750)}}applyTheme();function updateMotionBtn(){if(!motionBtn)return;motionBtn.innerHTML=animationsEnabled?'':'';motionBtn.title=animationsEnabled?"Toggle Animations":"Animations disabled"}function toggleAnimations(){animationsEnabled=!animationsEnabled;localStorage.setItem("crochetAnimations",animationsEnabled);updateMotionBtn();if(!animationsEnabled){stopAmbientDrift();document.body.classList.remove("theme-animating")}else{handleAmbientDrift()}}function openColorPicker(pId,partId){if(!colorOverlay||!colorGrid)return;const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));colorGrid.innerHTML=colors.map((c=>`\n \n `)).join("");if(customColorInput){customColorInput.value=part.color||project.color||colors[0];customColorInput.oninput=e=>{setPartColor(pId,partId,e.target.value)}}colorOverlay.classList.add("active");colorOverlay.dataset.projectId=pId;colorOverlay.dataset.partId=partId}function closeColorPicker(){if(!colorOverlay)return;colorOverlay.classList.remove("active");colorGrid.innerHTML="";colorOverlay.dataset.projectId="";colorOverlay.dataset.partId=""}function savePatterns(){localStorage.setItem("crochetPatterns",JSON.stringify(patterns))}function populatePatternSelect(){if(!patternPicker||!patternSelect)return;const hasPatterns=patterns.length>0;patternPicker.style.display=hasPatterns?"block":"none";patternSelect.innerHTML=''+patterns.map((p=>``)).join("")}function exportData(selectedProjects=projects){const payload={projects:selectedProjects,isDarkMode:isDarkMode,animationsEnabled:animationsEnabled,patterns:patterns};const names=selectedProjects.map((p=>p.name||"Project")).join("_").replace(/\s+/g,"-").slice(0,50)||"projects";const filename=`toadstool_${names}.json`;const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json"});const url=URL.createObjectURL(blob);const a=document.createElement("a");a.href=url;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url)}function triggerImport(){if(!importInput)return;importInput.value="";importInput.click()}async function handleImport(event){const file=event.target.files[0];if(!file)return;try{const text=await file.text();const data=JSON.parse(text);if(!data.projects||!Array.isArray(data.projects))throw new Error("Invalid file");projects=data.projects;if(typeof data.isDarkMode==="boolean"){isDarkMode=data.isDarkMode;localStorage.setItem("crochetDarkMode",isDarkMode)}if(typeof data.animationsEnabled==="boolean"){animationsEnabled=data.animationsEnabled;localStorage.setItem("crochetAnimations",animationsEnabled)}if(Array.isArray(data.patterns)){patterns=data.patterns;localStorage.setItem("crochetPatterns",JSON.stringify(patterns))}localStorage.setItem("crochetCounters",JSON.stringify(projects));applyTheme();render()}catch(err){showAlert({title:"Import failed",text:err.message})}event.target.value=""}if(importInput){importInput.addEventListener("change",handleImport)}function openSaveModal(){if(!saveOverlay||!saveList)return;saveList.innerHTML="";pendingSaveSelection=projects.map((p=>p.id));projects.forEach((p=>{const item=document.createElement("label");item.className="save-item";item.innerHTML=`\n \n ${p.name}\n `;saveList.appendChild(item)}));saveOverlay.classList.add("active")}function closeSaveModal(){if(!saveOverlay)return;saveOverlay.classList.remove("active");saveList.innerHTML=""}function exportSelected(){if(!saveOverlay)return;const inputs=saveList.querySelectorAll('input[type="checkbox"]');const selectedIds=Array.from(inputs).filter((i=>i.checked)).map((i=>Number(i.dataset.id)));if(selectedIds.length===0){closeSaveModal();return}const selectedProjects=projects.filter((p=>selectedIds.includes(p.id)));exportData(selectedProjects);closeSaveModal()}function spawnFirefly({markActive:markActive=false,source:source="ambient",side:side="any"}={}){const wrap=document.createElement("div");wrap.className="firefly-wrap";const el=document.createElement("div");el.className="firefly";const top=Math.random()*55+5;const scale=.9+Math.random()*.4;const duration=12+Math.random()*8;const chosenSide=side==="any"?["left","right","top","bottom"][Math.floor(Math.random()*4)]:side;let startX="-10vw",endX="110vw",startY=`${top}vh`,endY=`${top+(Math.random()*12-6)}vh`;let midX="25vw",midY=`${top-6}vh`,mid2X="65vw",mid2Y=`${top+6}vh`;if(chosenSide==="right"){startX="110vw";endX="-10vw";midX="-25vw";mid2X="-65vw"}else if(chosenSide==="top"){const x=Math.random()*80+10;startX=`${x}vw`;endX=`${x+(Math.random()*20-10)}vw`;startY="-12vh";endY="110vh";midX=`${x+8}vw`;mid2X=`${x-8}vw`;midY="25vh";mid2Y="65vh"}else if(chosenSide==="bottom"){const x=Math.random()*80+10;startX=`${x}vw`;endX=`${x+(Math.random()*20-10)}vw`;startY="110vh";endY="-12vh";midX=`${x-8}vw`;mid2X=`${x+8}vw`;midY="75vh";mid2Y="35vh"}wrap.style.setProperty("--fly-scale",scale);wrap.style.setProperty("--fly-duration",`${duration}s`);wrap.style.setProperty("--fly-start-x",startX);wrap.style.setProperty("--fly-start-y",startY);wrap.style.setProperty("--fly-mid-x",midX);wrap.style.setProperty("--fly-mid-y",midY);wrap.style.setProperty("--fly-mid2-x",mid2X);wrap.style.setProperty("--fly-mid2-y",mid2Y);wrap.style.setProperty("--fly-end-x",endX);wrap.style.setProperty("--fly-end-y",endY);if(markActive)fireflyActive=true;wrap.addEventListener("animationend",(e=>{if(e.animationName!=="fireflyGlide")return;wrap.remove();if(markActive)fireflyActive=false}));wrap.appendChild(el);document.body.appendChild(wrap)}function spawnSeed({markActive:markActive=false,source:source="ambient"}={}){const wrap=document.createElement("div");wrap.className="seed-wrap";const el=document.createElement("div");el.className="seed";const top=Math.random()*55+5;const scale=.85+Math.random()*.4;const duration=14+Math.random()*8;const tilt=(Math.random()*16+8)*(Math.random()<.5?-1:1);const sway=4+Math.random()*6;const flipDur=5+Math.random()*4;const dir=["left","right","top"][Math.floor(Math.random()*3)];const fromLeft=dir==="left";let start=fromLeft?"-12vw":"112vw";let mid=fromLeft?"30vw":"-30vw";let end=fromLeft?"112vw":"-12vw";if(dir==="top"){const x=Math.random()*80+10;start=`${x}vw`;mid=`${x+(Math.random()*10-5)}vw`;end=`${x+(Math.random()*20-10)}vw`;wrap.style.top="-12vh"}else{wrap.style.top=`${top}vh`}wrap.style.setProperty("--seed-scale",scale);wrap.style.setProperty("--seed-duration",`${duration}s`);wrap.style.setProperty("--seed-tilt",`${tilt}deg`);wrap.style.setProperty("--seed-sway",`${sway}px`);wrap.style.setProperty("--seed-flip-duration",`${flipDur}s`);wrap.style.setProperty("--seed-start",start);wrap.style.setProperty("--seed-mid",mid);wrap.style.setProperty("--seed-end",end);if(markActive)fireflyActive=true;wrap.addEventListener("animationend",(e=>{if(e.animationName!=="seedGlide")return;wrap.remove();if(markActive)fireflyActive=false}));wrap.appendChild(el);document.body.appendChild(wrap)}function stopAmbientDrift(){if(fireflyTimer){clearTimeout(fireflyTimer);fireflyTimer=null}document.querySelectorAll(".firefly-wrap").forEach((el=>el.remove()));document.querySelectorAll(".seed-wrap").forEach((el=>el.remove()));fireflyActive=false}function scheduleAmbientDrift(){const delay=1e4+Math.random()*1e4;fireflyTimer=setTimeout((()=>{if(!animationsEnabled){stopAmbientDrift();return}const selector=isDarkMode?".firefly-wrap":".seed-wrap";let existing=document.querySelectorAll(selector).length;if(existing===0){isDarkMode?spawnFirefly():spawnSeed();existing++}else if(existing<5){isDarkMode?spawnFirefly():spawnSeed()}scheduleAmbientDrift()}),delay)}function handleAmbientDrift(){stopAmbientDrift();if(!animationsEnabled)return;if(isDarkMode){spawnFirefly()}else{spawnSeed()}scheduleAmbientDrift()}handleAmbientDrift();const logoIcon=document.querySelector(".brand-icon");if(logoIcon){logoIcon.addEventListener("click",(()=>{if(!animationsEnabled||fireflyActive)return;if(isDarkMode){spawnFirefly({markActive:true,source:"logo",side:"any"})}else{spawnSeed({markActive:true,source:"logo"})}}))}if(colorOverlay){colorOverlay.addEventListener("click",(e=>{if(e.target===colorOverlay)closeColorPicker()}))}document.addEventListener("keydown",(e=>{if(e.key==="Escape"&&colorOverlay&&colorOverlay.classList.contains("active")){closeColorPicker()}}));const importBtn=document.getElementById("importBtn");if(importBtn){importBtn.addEventListener("click",triggerImport)}const titleEl=document.getElementById("appTitle");if(titleEl){titleEl.addEventListener("click",(()=>{const now=Date.now();titleClicks=titleClicks.filter((ts=>now-ts<7e3));titleClicks.push(now);if(titleClicks.length>=5&&!easterEggCooling){easterEggCooling=true;triggerBurst();setTimeout((()=>{easterEggCooling=false;titleClicks=[]}),8e3)}}))}function triggerBurst(){if(!animationsEnabled)return;const burstCount=isDarkMode?24:18;const spawner=isDarkMode?opts=>spawnFirefly({...opts,side:"any"}):spawnSeed;for(let i=0;ispawner({source:"burst"})),i*140+jitter)}}let wakeLock=null;let isFocusMode=false;const focusBtn=document.getElementById("focusBtn");if(projects.length>0){let changed=false;projects.forEach(((p,index)=>{if(!p.parts){p.parts=[];changed=true}if(!p.color){p.color=colors[index%colors.length];changed=true}const oldIdx=oldColors.indexOf(p.color);if(oldIdx!==-1){p.color=colors[oldIdx%colors.length];changed=true}if(p.note===undefined){p.note="";changed=true}p.parts.forEach((pt=>{if(pt.max===undefined){pt.max=null;changed=true}if(pt.note===undefined){pt.note="";changed=true}if(!pt.color){pt.color=p.color;changed=true}const oldPartIdx=oldColors.indexOf(pt.color);if(oldPartIdx!==-1){pt.color=colors[oldPartIdx%colors.length];changed=true}}))}));if(changed){localStorage.setItem("crochetCounters",JSON.stringify(projects))}}function save(){localStorage.setItem("crochetCounters",JSON.stringify(projects));render()}async function toggleFocusMode(){if(!isFocusMode){try{if(document.documentElement.requestFullscreen)await document.documentElement.requestFullscreen();if("wakeLock"in navigator){wakeLock=await navigator.wakeLock.request("screen")}isFocusMode=true;focusBtn.classList.add("is-active")}catch(err){showAlert({title:"Focus Mode failed",text:err.message})}}else{if(document.fullscreenElement)document.exitFullscreen();if(wakeLock!==null){wakeLock.release();wakeLock=null}isFocusMode=false;focusBtn.classList.remove("is-active")}}document.addEventListener("visibilitychange",(async()=>{if(wakeLock!==null&&document.visibilityState==="visible"){wakeLock=await navigator.wakeLock.request("screen")}}));async function deleteProject(pId){const ok=await showConfirm({title:"Delete project?",text:"This will remove the entire project.",confirmText:"Delete",danger:true});if(ok){projects=projects.filter((p=>p.id!==pId));save()}}function toggleProjectCollapse(pId){const project=projects.find((p=>p.id===pId));project.collapsed=!project.collapsed;save()}function renameProject(pId){modalState={type:"renameProject",pId:pId,partId:null};const project=projects.find((p=>p.id===pId));modalTitle.innerText="Rename Project";modalInput.value=project.name;modalInput.type="text";modalInput.placeholder="Project name";modal.classList.add("active");setTimeout((()=>modalInput.focus()),100)}async function deletePart(pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));if(part.locked)return;const ok=await showConfirm({title:"Delete part?",text:"This part will be removed.",confirmText:"Delete",danger:true});if(ok){project.parts=project.parts.filter((pt=>pt.id!==partId));save()}}function togglePartMinimize(pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));part.minimized=!part.minimized;save()}function togglePartLock(pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));part.locked=!part.locked;save()}function setPartColor(pId,partId,color){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));part.color=color;save()}function togglePartFinish(pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));part.finished=!part.finished;if(part.finished){part.locked=false;lastFinishedId=part.id}else{lastFinishedId=null}save()}function updateCount(pId,partId,change){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));if(part.locked||part.finished)return;part.count+=change;if(part.max!==null&&part.count>part.max)part.count=part.max;if(part.count<0)part.count=0;hapticTick();lastCountPulse={partId:partId,dir:change>0?"up":"down"};save()}async function resetCount(pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));if(part.locked||part.finished)return;const ok=await showConfirm({title:"Reset count?",text:"Set this count back to zero.",confirmText:"Reset",danger:true});if(ok){part.count=0;save()}}function saveProjectAsPattern(pId){const project=projects.find((p=>p.id===pId));if(!project)return;openModal("savePattern",pId)}function openModal(type,pId=null,partId=null){modalState={type:type,pId:pId,partId:partId};modalInput.value="";if(type==="addProject"){modalTitle.innerText="New Project Name";modalInput.type="text";modalInput.placeholder="e.g., Amigurumi Bear";if(patternPicker&&patternSelect){populatePatternSelect();patternSelect.value=""}}else if(type==="addPart"){modalTitle.innerText="New Part Name";modalInput.type="text";modalInput.placeholder="e.g., Head"}else if(type==="setMax"){const part=projects.find((p=>p.id===pId)).parts.find((pt=>pt.id===partId));modalTitle.innerText="Set Max Stitches";modalInput.value=part.max??"";modalInput.type="number";modalInput.placeholder="Leave blank to clear"}else if(type==="renamePart"){const part=projects.find((p=>p.id===pId)).parts.find((pt=>pt.id===partId));if(part.locked||part.finished)return;modalTitle.innerText="Rename Part";modalInput.value=part.name;modalInput.type="text"}else if(type==="savePattern"){modalTitle.innerText="Save as Pattern";const project=projects.find((p=>p.id===pId));modalInput.value=project?`${project.name} pattern`:"";modalInput.type="text";modalInput.placeholder="Pattern name"}else if(type==="manualCount"){const part=projects.find((p=>p.id===pId)).parts.find((pt=>pt.id===partId));if(part.locked||part.finished)return;modalTitle.innerText="Set Row Count";modalInput.value=part.count;modalInput.type="number"}else if(type==="setMax"){const part=projects.find((p=>p.id===pId)).parts.find((pt=>pt.id===partId));if(part.locked)return}if(type!=="addProject"&&patternPicker){patternPicker.style.display="none"}modal.classList.add("active");setTimeout((()=>modalInput.focus()),100)}function closeModal(){modal.classList.remove("active");modalInput.blur()}function saveModal(){const val=modalInput.value.trim();if(!val&&modalState.type!=="manualCount"&&modalState.type!=="setMax")return closeModal();if(modalState.type==="addProject"){const nextColor=colors[projects.length%colors.length];const newProject={id:Date.now(),name:val,color:nextColor,collapsed:false,note:"",parts:[]};const selectedPatternId=patternSelect?patternSelect.value:"";const chosenPattern=selectedPatternId?patterns.find((p=>String(p.id)===selectedPatternId)):null;if(chosenPattern&&Array.isArray(chosenPattern.parts)&&chosenPattern.parts.length){chosenPattern.parts.forEach(((pt,idx)=>{newProject.parts.push({id:Date.now()+idx+1,name:pt.name||`Part ${idx+1}`,count:0,locked:false,finished:false,minimized:false,max:pt.max??null,color:pt.color||newProject.color,note:""})}))}else{newProject.parts.push({id:Date.now()+1,name:"Part 1",count:0,locked:false,finished:false,minimized:false,max:null,color:nextColor,note:""})}projects.push(newProject)}else if(modalState.type==="addPart"){const project=projects.find((p=>p.id===modalState.pId));project.parts.push({id:Date.now(),name:val,count:0,locked:false,finished:false,minimized:false,max:null,color:project.color,note:""});project.collapsed=false}else if(modalState.type==="renamePart"){const part=projects.find((p=>p.id===modalState.pId)).parts.find((pt=>pt.id===modalState.partId));part.name=val}else if(modalState.type==="renameProject"){const project=projects.find((p=>p.id===modalState.pId));project.name=val}else if(modalState.type==="manualCount"){const num=parseInt(val);if(!isNaN(num)&&num>=0){const part=projects.find((p=>p.id===modalState.pId)).parts.find((pt=>pt.id===modalState.partId));part.count=num;if(part.max!==null&&part.count>part.max)part.count=part.max}}else if(modalState.type==="setMax"){const part=projects.find((p=>p.id===modalState.pId)).parts.find((pt=>pt.id===modalState.partId));if(val===""){part.max=null}else{const num=parseInt(val);if(!isNaN(num)&&num>0){part.max=num;if(part.count>part.max)part.count=part.max}}}else if(modalState.type==="savePattern"){const project=projects.find((p=>p.id===modalState.pId));if(project){const template=project.parts.map((pt=>({name:pt.name,color:pt.color,max:pt.max})));patterns.push({id:Date.now(),name:val||project.name,color:project.color,parts:template});savePatterns();populatePatternSelect()}}save();closeModal()}modalInput.addEventListener("keyup",(e=>{if(e.key==="Enter")saveModal()}));function toggleNote(id){const el=document.getElementById(id);if(!el)return;el.classList.toggle("show")}function updateProjectNote(e,pId){const project=projects.find((p=>p.id===pId));project.note=e.target.value;localStorage.setItem("crochetCounters",JSON.stringify(projects))}function updatePartNote(e,pId,partId){const project=projects.find((p=>p.id===pId));const part=project.parts.find((pt=>pt.id===partId));part.note=e.target.value;localStorage.setItem("crochetCounters",JSON.stringify(projects))}function render(){app.innerHTML="";if(projects.length===0){app.innerHTML='
Toadstools & twine await...
Tap + to begin a new project.
';return}const grid=document.createElement("div");grid.className="projects-grid";projects.forEach((project=>{const sortedParts=[...project.parts].sort(((a,b)=>a.finished-b.finished));const projectCollapsedClass=project.collapsed?"project-collapsed":"";let partsHtml="";sortedParts.forEach((part=>{const accent=part.color||project.color;const isLocked=part.locked?"is-locked":"";const isFinished=part.finished?"is-finished":"";const isMinimized=part.minimized?"is-minimized":"";const lockIcon=part.locked?'':'';const lockBtnClass=part.locked?"btn-lock locked-active":"btn-lock";const controlsDimmed=part.locked||part.finished?"dimmed":"";const hideControls=part.finished||part.minimized?"hidden-controls":"";const showSetMax=part.minimized?"hidden":"";const partNoteId=`part-note-${project.id}-${part.id}`;const countId=`count-${part.id}`;const pulseClass=lastCountPulse&&lastCountPulse.partId===part.id?lastCountPulse.dir==="up"?"count-bump-up":"count-bump-down":"";const finishPulseClass=part.finished&&lastFinishedId===part.id?"finish-shimmer":"";const partCardId=`part-${part.id}`;const partCardFullClass=`${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass}`;const lockDisabled=part.locked?"disabled":"";const actionsHtml=part.minimized?`
`:`
\n \n \n \n \n
`;const countSubtext=part.minimized?"":`\n
\n ${part.max!==null?`${part.count} / ${part.max}`:"No max set"}\n \n
\n `;partsHtml+=`\n
\n
\n
\n \n ${part.name}\n ${part.count}\n
\n ${actionsHtml}\n
\n
${part.count}
\n ${countSubtext}\n
\n \n \n \n
\n
\n \n
\n \n
`}));const projectContainer=document.createElement("div");projectContainer.className=`project-container ${projectCollapsedClass}`;projectContainer.style=`--project-color: ${project.color}`;const projectNoteId=`project-note-${project.id}`;projectContainer.innerHTML=`\n
\n
\n \n ${project.name}\n \n
\n
\n \n \n \n
\n
\n
\n \n
\n \n
${partsHtml}
\n `;grid.appendChild(projectContainer)}));lastCountPulse=null;lastFinishedId=null;app.appendChild(grid)}render(); \ No newline at end of file +// --- Data Init & Colors --- +let projects = JSON.parse(localStorage.getItem('crochetCounters')) || []; +let patterns = JSON.parse(localStorage.getItem('crochetPatterns')) || []; +if (!Array.isArray(patterns)) patterns = []; +let saveFlashTimer = null; +const crochetAbbrev = [ + { code: 'alt', desc: 'alternate' }, + { code: 'approx', desc: 'approximately' }, + { code: 'beg', desc: 'begin/beginning' }, + { code: 'bet', desc: 'between' }, + { code: 'bl/blo', desc: 'back loop/back loop only' }, + { code: 'bo', desc: 'bobble' }, + { code: 'bp', desc: 'back post' }, + { code: 'bpdc', desc: 'back post double crochet' }, + { code: 'bpdtr', desc: 'back post double treble crochet' }, + { code: 'bphdc', desc: 'back post half double crochet' }, + { code: 'bpsc', desc: 'back post single crochet' }, + { code: 'bptr', desc: 'back post treble crochet' }, + { code: 'cc', desc: 'contrasting color' }, + { code: 'ch', desc: 'chain stitch' }, + { code: 'ch-sp', desc: 'chain space' }, + { code: 'cl', desc: 'cluster' }, + { code: 'cont', desc: 'continue' }, + { code: 'dc', desc: 'double crochet' }, + { code: 'dc2tog', desc: 'double crochet 2 stitches together' }, + { code: 'dec', desc: 'decrease' }, + { code: 'dtr', desc: 'double treble crochet' }, + { code: 'edc', desc: 'extended double crochet' }, + { code: 'ehdc', desc: 'extended half double crochet' }, + { code: 'esc', desc: 'extended single crochet' }, + { code: 'etr', desc: 'extended treble crochet' }, + { code: 'fl/flo', desc: 'front loop/front loop only' }, + { code: 'foll', desc: 'following' }, + { code: 'fp', desc: 'front post' }, + { code: 'fpdc', desc: 'front post double crochet' }, + { code: 'fpdtr', desc: 'front post double treble crochet' }, + { code: 'fphdc', desc: 'front post half double crochet' }, + { code: 'fpsc', desc: 'front post single crochet' }, + { code: 'fptr', desc: 'front post treble crochet' }, + { code: 'hdc', desc: 'half double crochet' }, + { code: 'hdc2tog', desc: 'half double crochet 2 stitches together' }, + { code: 'inc', desc: 'increase' }, + { code: 'lp', desc: 'loop' }, + { code: 'm', desc: 'marker' }, + { code: 'mc', desc: 'main color' }, + { code: 'pat', desc: 'pattern' }, + { code: 'pc', desc: 'popcorn stitch' }, + { code: 'pm', desc: 'place marker' }, + { code: 'prev', desc: 'previous' }, + { code: 'ps/puff', desc: 'puff stitch' }, + { code: 'rem', desc: 'remaining' }, + { code: 'rep', desc: 'repeat' }, + { code: 'rnd', desc: 'round' }, + { code: 'rs', desc: 'right side' }, + { code: 'sc', desc: 'single crochet' }, + { code: 'sc2tog', desc: 'single crochet 2 stitches together' }, + { code: 'sh', desc: 'shell' }, + { code: 'sk', desc: 'skip' }, + { code: 'sl st', desc: 'slip stitch' }, + { code: 'sm/sl m', desc: 'slip marker' }, + { code: 'sp', desc: 'space' }, + { code: 'st', desc: 'stitch' }, + { code: 'tbl', desc: 'through back loop' }, + { code: 'tch/t-ch', desc: 'turning chain' }, + { code: 'tog', desc: 'together' }, + { code: 'tr', desc: 'treble crochet' }, + { code: 'tr2tog', desc: 'treble crochet 2 stitches together' }, + { code: 'trtr', desc: 'triple treble crochet' }, + { code: 'ws', desc: 'wrong side' }, + { code: 'yo', desc: 'yarn over' }, + { code: 'yoh', desc: 'yarn over hook' }, + // Tunisian + { code: 'etss', desc: 'extended Tunisian simple stitch' }, + { code: 'fwp', desc: 'forward pass' }, + { code: 'retp', desc: 'return pass' }, + { code: 'tdc', desc: 'Tunisian double crochet' }, + { code: 'tfs', desc: 'Tunisian full stitch' }, + { code: 'thdc', desc: 'Tunisian half double crochet' }, + { code: 'tks', desc: 'Tunisian knit stitch' }, + { code: 'tps', desc: 'Tunisian purl stitch' }, + { code: 'trs', desc: 'Tunisian reverse stitch' }, + { code: 'tsc', desc: 'Tunisian single crochet' }, + { code: 'tss', desc: 'Tunisian simple stitch' }, + { code: 'tslst', desc: 'Tunisian slip stitch' }, + { code: 'ttr', desc: 'Tunisian treble crochet' }, + { code: 'ttw', desc: 'Tunisian twisted' } +]; + +const knitAbbrev = [ + { code: 'alt', desc: 'alternate' }, + { code: 'approx', desc: 'approximately' }, + { code: 'beg', desc: 'beginning' }, + { code: 'bet', desc: 'between' }, + { code: 'BO', desc: 'bind off' }, + { code: 'byo', desc: 'backward yarn over' }, + { code: 'CC', desc: 'contrasting color' }, + { code: 'cn', desc: 'cable needle' }, + { code: 'CO', desc: 'cast on' }, + { code: 'cont', desc: 'continue' }, + { code: 'dec', desc: 'decrease' }, + { code: 'dpn', desc: 'double-pointed needles' }, + { code: 'foll', desc: 'follow' }, + { code: 'inc', desc: 'increase' }, + { code: 'k', desc: 'knit' }, + { code: 'k1B', desc: 'knit stitch in row below' }, + { code: 'kfb', desc: 'knit 1 front and back (inc)' }, + { code: 'ksp', desc: 'knit 1, slip back, pass over (dec)' }, + { code: 'k2tog', desc: 'knit 2 together (dec)' }, + { code: 'kwise', desc: 'knitwise' }, + { code: 'LH', desc: 'left hand' }, + { code: 'lp', desc: 'loop' }, + { code: 'm', desc: 'marker' }, + { code: 'M1', desc: 'make one knitwise (inc)' }, + { code: 'M1R', desc: 'make one right (inc)' }, + { code: 'M1L', desc: 'make one left (inc)' }, + { code: 'M1p', desc: 'make one purlwise (inc)' }, + { code: 'M1rp', desc: 'make one right purlwise (inc)' }, + { code: 'M1lp', desc: 'make one left purlwise (inc)' }, + { code: 'MC', desc: 'main color' }, + { code: 'p', desc: 'purl' }, + { code: 'pat', desc: 'pattern' }, + { code: 'pfb', desc: 'purl front and back (inc)' }, + { code: 'pm', desc: 'place marker' }, + { code: 'p2tog', desc: 'purl 2 together (dec)' }, + { code: 'prev', desc: 'previous' }, + { code: 'psso', desc: 'pass slipped stitch over' }, + { code: 'p2sso', desc: 'pass 2 slipped stitches over' }, + { code: 'pwise', desc: 'purlwise' }, + { code: 'rem', desc: 'remaining' }, + { code: 'rep', desc: 'repeat' }, + { code: 'rev St st', desc: 'reverse stockinette stitch' }, + { code: 'RH', desc: 'right hand' }, + { code: 'rnd', desc: 'round' }, + { code: 'RS', desc: 'right side' }, + { code: 'SKP', desc: 'slip 1 k-wise, k1, pass slipped over (dec)' }, + { code: 'SK2P', desc: 'slip 1 k-wise, k2tog, pass slipped over (dec)' }, + { code: 'sl', desc: 'slip' }, + { code: 'sl1k', desc: 'slip 1 knitwise' }, + { code: 'sl1p', desc: 'slip 1 purlwise' }, + { code: 'sl st', desc: 'slip stitch' }, + { code: 'sm', desc: 'slip marker' }, + { code: 'ssk', desc: 'slip 2 k-wise, k through back loops (dec)' }, + { code: 'ssp', desc: 'slip 2 k-wise, return, p through back loops (dec)' }, + { code: 'sssk', desc: 'slip 3 k-wise, k through back loops (double dec)' }, + { code: 'sssp', desc: 'slip 3 k-wise, return, p through back loops (double dec)' }, + { code: 'S2KP2', desc: 'slip 2 as if to k2tog, k1, pass 2 over (double dec)' }, + { code: 'SSPP2', desc: 'slip 2 k-wise, return, p2tog tbl, p1, pass 2 over (double dec)' }, + { code: 'st', desc: 'stitch' }, + { code: 'St st', desc: 'stockinette stitch' }, + { code: 'tbl', desc: 'through back loop' }, + { code: 'tfl', desc: 'through front loop' }, + { code: 'tog', desc: 'together' }, + { code: 'WS', desc: 'wrong side' }, + { code: 'w&t', desc: 'wrap and turn' }, + { code: 'wyib', desc: 'with yarn in back' }, + { code: 'wyif', desc: 'with yarn in front' }, + { code: 'yb', desc: 'yarn back' }, + { code: 'yfwd/yf', desc: 'yarn forward' }, + { code: 'yo', desc: 'yarn over' }, + { code: 'yon', desc: 'yarn over needle' }, + { code: 'yrn', desc: 'yarn round needle' } +]; + +const crochetBuckets = [ + { label: 'Core stitches', codes: ['ch', 'sc', 'hdc', 'dc', 'tr', 'sl st'] }, + { label: 'Shaping & repeats', codes: ['inc', 'dec', 'tog', 'rep', 'rem', 'rnd', 'rs', 'ws', 'tch/t-ch'] }, + { label: 'Placement & loops', codes: ['fl/flo', 'bl/blo', 'tbl', 'lp', 'sp', 'ch-sp', 'prev'] }, + { label: 'Post stitches', codes: ['fp', 'fpdc', 'fptr', 'fpsc', 'fphdc', 'fpdtr', 'bp', 'bpdc', 'bptr', 'bpsc', 'bphdc', 'bpdtr'] }, + { label: 'Extended & special', codes: ['pat', 'pc', 'ps/puff', 'cl', 'sh', 'dtr', 'edc', 'ehdc', 'esc', 'etr', 'tr2tog', 'trtr', 'yo', 'yoh'] }, + { label: 'Markers & color', codes: ['mc', 'cc', 'm', 'pm', 'sm/sl m'] }, + { label: 'Tunisian', codes: ['etss', 'fwp', 'retp', 'tdc', 'tfs', 'thdc', 'tks', 'tps', 'trs', 'tsc', 'tss', 'tslst', 'ttr', 'ttw'] }, + { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont', 'foll', 'pat', 'prev'] } +]; + +const knitBuckets = [ + { label: 'Core stitches', codes: ['k', 'p', 'yo', 'sl st', 'St st', 'rev St st'] }, + { label: 'Increases', codes: ['inc', 'M1', 'M1R', 'M1L', 'M1p', 'M1rp', 'M1lp', 'kfb', 'pfb'] }, + { label: 'Decreases', codes: ['dec', 'k2tog', 'ssk', 'SKP', 'SK2P', 'ssp', 'sssk', 'sssp', 'S2KP2', 'SSPP2', 'p2tog', 'psso', 'p2sso', 'ksp', 'tog'] }, + { label: 'Slips & markers', codes: ['sl', 'sl1k', 'sl1p', 'm', 'pm', 'sm', 'cn', 'dpn'] }, + { label: 'Yarn moves', codes: ['wyib', 'wyif', 'yfwd/yf', 'yb', 'yon', 'yrn', 'w&t'] }, + { label: 'Cast on / bind off', codes: ['CO', 'BO'] }, + { label: 'Color & pattern', codes: ['MC', 'CC', 'pat'] }, + { label: 'Positioning', codes: ['k1B', 'tbl', 'tfl', 'lp', 'prev'] }, + { label: 'Rows & sides', codes: ['rep', 'rem', 'RS', 'WS', 'rnd', 'foll'] }, + { label: 'General terms', codes: ['alt', 'approx', 'beg', 'bet', 'cont'] } +]; + +const repeatTokens = ['*', 'rep from *', '[', ']', '(', ')', 'to end']; +const nonMergeTokens = new Set(['*', '[', ']', '(', ')', 'rep from *', 'to end']); + +function getActiveAbbrevLibrary() { + return patternDraft.mode === 'knit' ? knitAbbrev : crochetAbbrev; +} + +function getAbbrevByCode(code) { + return crochetAbbrev.find(a => a.code === code) || knitAbbrev.find(a => a.code === code); +} + +function buildAbbrevGroups(library, buckets) { + const index = new Map(library.map(item => [item.code, item])); + const used = new Set(); + const groups = []; + buckets.forEach(bucket => { + const items = []; + bucket.codes.forEach(code => { + const item = index.get(code); + if (item && !used.has(code)) { + items.push(item); + used.add(code); + } + }); + if (items.length) groups.push({ label: bucket.label, items }); + }); + const leftover = library.filter(item => !used.has(item.code)); + if (leftover.length) groups.push({ label: 'Other', items: leftover }); + return groups; +} + +function getAbbrevGroups() { + const library = getActiveAbbrevLibrary(); + const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; + return buildAbbrevGroups(library, buckets); +} + +function getCoreCodes() { + const buckets = patternDraft.mode === 'knit' ? knitBuckets : crochetBuckets; + const core = buckets.find(b => b.label === 'Core stitches'); + return core ? core.codes : []; +} + +function getPatternButtonCodes() { + const base = (patternDraft.abbrevSelection && patternDraft.abbrevSelection.length) + ? patternDraft.abbrevSelection + : (patternDraft.mode === 'knit' + ? ['k', 'p', 'yo', 'k2tog', 'ssk', 'sl st'] + : ['ch', 'sc', 'hdc', 'dc', 'tr', 'inc', 'dec', 'sl st', 'sk', 'rep']); + const merged = [...base, ...repeatTokens]; + return Array.from(new Set(merged)); +} +function normalizePatternDraft(d = {}) { + const baseStep = { title: '', rows: [], rowDraft: '', note: '', image: '' }; + const base = { + mode: 'crochet', + currentRow: 1, + line: '', + output: '', + meta: { title: '', designer: '' }, + materials: '', + gauge: '', + gaugeSts: '', + gaugeRows: '', + gaugeHook: '', + size: '', + abbrev: '', + abbrevSelection: [], + stitches: '', + notes: '', + steps: [] + }; + const merged = { ...base, ...d }; + merged.meta = { ...base.meta, ...(d.meta || {}) }; + merged.abbrevSelection = Array.isArray(merged.abbrevSelection) ? merged.abbrevSelection : []; + merged.steps = Array.isArray(merged.steps) ? merged.steps.map(s => ({ ...baseStep, ...s })) : []; + merged.materials = merged.materials || ''; + merged.gauge = merged.gauge || ''; + merged.gaugeSts = merged.gaugeSts || ''; + merged.gaugeRows = merged.gaugeRows || ''; + merged.gaugeHook = merged.gaugeHook || ''; + merged.size = merged.size || ''; + merged.abbrev = merged.abbrev || ''; + merged.stitches = merged.stitches || ''; + merged.notes = merged.notes || ''; + if (!merged.mode) merged.mode = 'crochet'; + if (!merged.currentRow) merged.currentRow = 1; + if (merged.line === undefined) merged.line = ''; + if (merged.output === undefined) merged.output = ''; + return merged; +} + +let patternDraft = normalizePatternDraft(JSON.parse(localStorage.getItem('crochetPatternDraft')) || {}); +// New Earthy/Woodland Palette extracted from image vibes +const colors = [ + '#a17d63', // Soft oak + '#7a8c6a', // Moss sage + '#c7a272', // Warm amber + '#b88b8a', // Rose clay + '#7b9189', // Sage teal + '#aa9a7a', // Wheat linen + '#5f6d57', // Forest dusk + '#b07d6f', // Terra blush + '#6d5947', // Walnut + '#c4b08a', // Oat + '#7c7565', // Stone + '#8ca58c' // Meadow +]; +const oldColors = [ + '#b56b54', // Rust/Mushroom + '#7a8b4f', // Olive Green + '#cba052', // Mustard/Daisy center + '#5f8a8b', // Muted Teal + '#8c6246', // Warm Wood tone + '#a87b8c', // Dusty Rose + '#4a5d43', // Deep Forest + '#9c7e63' // Taupe +]; + +// --- State Variables --- +let modalState = { type: null, projectId: null, partId: null }; +const app = document.getElementById('app'); +const modal = document.getElementById('modalOverlay'); +const modalInput = document.getElementById('modalInput'); +const modalTitle = document.getElementById('modalTitle'); +const hapticTick = () => { if ('vibrate' in navigator) navigator.vibrate(12); }; +const installBtn = document.getElementById('installBtn'); +const importInput = document.getElementById('importFile'); +const motionBtn = document.getElementById('motionBtn'); +const colorOverlay = document.getElementById('colorOverlay'); +const colorGrid = document.getElementById('colorGrid'); +const customColorInput = document.getElementById('customColorInput'); +const saveOverlay = document.getElementById('saveOverlay'); +const saveList = document.getElementById('saveList'); +const patternPicker = document.getElementById('patternPicker'); +const patternSelect = document.getElementById('patternSelect'); +const patternOverlay = document.getElementById('patternOverlay'); +const patternRowNumber = null; +const patternLine = null; +const patternOutput = document.getElementById('patternOutput'); +const patternButtonsWrap = null; +if (patternPicker && patternSelect) populatePatternSelect(); +if (patternOverlay) { + patternOverlay.addEventListener('click', (e) => { + if (e.target === patternOverlay) closePatternComposer(); + }); +} +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && patternOverlay && patternOverlay.classList.contains('active')) { + closePatternComposer(); + } + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && patternOverlay && patternOverlay.classList.contains('active')) { + e.preventDefault(); + addStep(); + } +}); + +function persistPatternDraft() { + localStorage.setItem('crochetPatternDraft', JSON.stringify(patternDraft)); + flashSave(); +} + +function setPatternMode(mode) { + patternDraft.mode = mode; + persistPatternDraft(); + if (!document.querySelector('.pattern-overlay')) return; + document.querySelectorAll('.pattern-mode').forEach(btn => { + btn.classList.toggle('is-active', btn.dataset.mode === mode); + }); + renderAbbrevChecklist(); + renderSteps(); +} + +function loadDefaultAbbrev() { + patternDraft.abbrevSelection = getCoreCodes(); + updateAbbrevFromSelection(); + renderAbbrevChecklist(); +} + +function bindPatternInputs() { + const titleEl = document.getElementById('patternTitle'); + const designerEl = document.getElementById('patternDesigner'); + const materialsEl = document.getElementById('patternMaterials'); + const gaugeEl = document.getElementById('patternGauge'); + const gaugeStsEl = document.getElementById('patternGaugeSts'); + const gaugeRowsEl = document.getElementById('patternGaugeRows'); + const gaugeHookEl = document.getElementById('patternGaugeHook'); + const sizeEl = document.getElementById('patternSize'); + const abbrevEl = document.getElementById('patternAbbrev'); + const stitchesEl = document.getElementById('patternStitches'); + const notesEl = document.getElementById('patternNotes'); + const outputEl = document.getElementById('patternOutput'); + if (titleEl) titleEl.addEventListener('input', e => { updateMetaField('title', e.target.value); }); + if (designerEl) designerEl.addEventListener('input', e => { updateMetaField('designer', e.target.value); }); + if (materialsEl) materialsEl.addEventListener('input', e => { updatePatternField('materials', e.target.value); }); + if (gaugeEl) gaugeEl.addEventListener('input', e => { updatePatternField('gauge', e.target.value); }); + if (gaugeStsEl) gaugeStsEl.addEventListener('input', e => { updatePatternField('gaugeSts', e.target.value); }); + if (gaugeRowsEl) gaugeRowsEl.addEventListener('input', e => { updatePatternField('gaugeRows', e.target.value); }); + if (gaugeHookEl) gaugeHookEl.addEventListener('input', e => { updatePatternField('gaugeHook', e.target.value); }); + if (sizeEl) sizeEl.addEventListener('input', e => { updatePatternField('size', e.target.value); }); + if (abbrevEl) abbrevEl.addEventListener('input', e => { updatePatternField('abbrev', e.target.value); }); + if (stitchesEl) stitchesEl.addEventListener('input', e => { updatePatternField('stitches', e.target.value); }); + if (notesEl) notesEl.addEventListener('input', e => { updatePatternField('notes', e.target.value); }); + if (outputEl) outputEl.addEventListener('input', e => { + patternDraft.output = e.target.value; + const lines = patternDraft.output.split('\n').filter(l => l.trim() !== ''); + patternDraft.currentRow = Math.max(1, lines.length + 1); + persistPatternDraft(); + }); +} + +function syncPatternUI() { + setPatternMode(patternDraft.mode); + const titleEl = document.getElementById('patternTitle'); + const designerEl = document.getElementById('patternDesigner'); + const materialsEl = document.getElementById('patternMaterials'); + const gaugeEl = document.getElementById('patternGauge'); + const gaugeStsEl = document.getElementById('patternGaugeSts'); + const gaugeRowsEl = document.getElementById('patternGaugeRows'); + const gaugeHookEl = document.getElementById('patternGaugeHook'); + const sizeEl = document.getElementById('patternSize'); + const abbrevEl = document.getElementById('patternAbbrev'); + const stitchesEl = document.getElementById('patternStitches'); + const notesEl = document.getElementById('patternNotes'); + if (titleEl) titleEl.value = patternDraft.meta.title; + if (designerEl) designerEl.value = patternDraft.meta.designer; + if (materialsEl) materialsEl.value = patternDraft.materials; + if (gaugeEl) gaugeEl.value = patternDraft.gauge; + if (gaugeStsEl) gaugeStsEl.value = patternDraft.gaugeSts; + if (gaugeRowsEl) gaugeRowsEl.value = patternDraft.gaugeRows; + if (gaugeHookEl) gaugeHookEl.value = patternDraft.gaugeHook; + if (sizeEl) sizeEl.value = patternDraft.size; + if (abbrevEl) abbrevEl.value = patternDraft.abbrev; + if (stitchesEl) stitchesEl.value = patternDraft.stitches; + if (notesEl) notesEl.value = patternDraft.notes; + renderSteps(); + renderAbbrevChecklist(); +} + +function addPatternToken(tok) { + if (!patternLine) return; + patternDraft.line = patternDraft.line ? `${patternDraft.line} ${tok}` : tok; + patternLine.value = patternDraft.line; + persistPatternDraft(); +} + +function clearPatternLine() { + if (!patternLine) return; + patternDraft.line = ''; + patternLine.value = ''; + persistPatternDraft(); +} + +function addPatternRow() { + if (!patternLine || !patternOutput) return; + const line = patternDraft.line.trim(); + if (!line) return; + const rowLabel = patternDraft.mode === 'knit' ? 'Row' : 'Rnd'; + const rowText = `${rowLabel} ${patternDraft.currentRow}: ${line}`; + patternDraft.output = patternDraft.output ? `${patternDraft.output}\n${rowText}` : rowText; + patternDraft.currentRow += 1; + patternDraft.line = ''; + persistPatternDraft(); + syncPatternUI(); +} + +function removePatternRow() { + if (!patternOutput) return; + const lines = (patternDraft.output || '').split('\n').filter(l => l.trim() !== ''); + if (lines.length === 0) return; + lines.pop(); + patternDraft.output = lines.join('\n'); + patternDraft.currentRow = Math.max(1, lines.length + 1); + persistPatternDraft(); + syncPatternUI(); +} + +function clearPatternOutput() { + if (!patternOutput) return; + patternDraft.output = ''; + patternDraft.currentRow = 1; + patternDraft.line = ''; + patternDraft.meta = { title: '', designer: '' }; + patternDraft.materials = ''; + patternDraft.gauge = ''; + patternDraft.gaugeSts = ''; + patternDraft.gaugeRows = ''; + patternDraft.gaugeHook = ''; + patternDraft.size = ''; + patternDraft.abbrev = ''; + patternDraft.stitches = ''; + patternDraft.notes = ''; + patternDraft.steps = []; + if (patternOutput) patternOutput.value = ''; + persistPatternDraft(); + syncPatternUI(); +} + +function exportPatternJSON() { + const payload = { patternDraft }; + const name = patternDraft.meta.title || 'pattern'; + const filename = `${name.replace(/\s+/g,'_').toLowerCase()}_draft.json`; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function importPatternJSON() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + if (!data.patternDraft) throw new Error('Invalid file'); + patternDraft = normalizePatternDraft(data.patternDraft); + persistPatternDraft(); + syncPatternUI(); + } catch (err) { + showAlert({ title: 'Import failed', text: err.message }); + } + }; + input.click(); +} + +function exportPatternPDF() { + const w = window.open('', '_blank'); + if (!w) return; + const styles = getComputedStyle(document.body); + const themeBg = styles.getPropertyValue('--bg') || '#f4f0e8'; + const themeText = styles.getPropertyValue('--text') || '#2f2b28'; + const accent = styles.getPropertyValue('--project-color') || '#7a8c6a'; + const gaugeBlock = `${patternDraft.gaugeSts || ''}${patternDraft.gaugeRows ? ' • ' + patternDraft.gaugeRows : ''}${patternDraft.gaugeHook ? ' • ' + patternDraft.gaugeHook : ''}`; + const fontLink = ''; + const html = ` + + + ${patternDraft.meta.title || 'Pattern'} - PDF + ${fontLink} + + + +

${patternDraft.meta.title || 'Pattern'}

+

${patternDraft.meta.designer || ''}

+
+
Materials
+
${patternDraft.materials || ''}
+
+
+
Gauge / Size
+
${gaugeBlock}\n${patternDraft.gauge || ''}\n${patternDraft.size || ''}
+
+
+
Abbreviations
+
${patternDraft.abbrev || ''}
+
+
+
Stitch Guide
+
${patternDraft.stitches || ''}
+
+
+
Steps
+ ${patternDraft.steps.map((s, i) => { + const rows = (s.rows || []).map((r, idx) => `Row ${idx + 1}: ${r}`).join('
'); + const note = s.note ? `
${s.note}` : ''; + return `
Step ${i + 1}${s.title ? ': ' + s.title : ''}
${rows}${note}
`; + }).join('
')} +
+
+
Rows
+
${patternDraft.output || ''}
+
+
+
Notes
+
${patternDraft.notes || ''}
+
+ + + `; + w.document.write(html); + w.document.close(); + w.focus(); + w.print(); +} + +function showPatternTab(tab) { + document.querySelectorAll('.pattern-tab').forEach(btn => btn.classList.toggle('is-active', btn.dataset.tab === tab)); + document.querySelectorAll('.pattern-section').forEach(sec => sec.classList.toggle('is-active', sec.dataset.section === tab)); +} + +function updateMetaField(field, value) { + patternDraft.meta[field] = value; + persistPatternDraft(); +} + +function updatePatternField(field, value) { + patternDraft[field] = value; + persistPatternDraft(); +} + +function renderSteps() { + const container = document.getElementById('patternSteps'); + if (!container) return; + container.innerHTML = ''; + patternDraft.steps.forEach((step, idx) => { + const card = document.createElement('div'); + card.className = 'pattern-step-card card-pop'; + const rows = step.rows || []; + const rowList = rows.map((r, i) => ` +
+ + + +
+ `).join(''); + card.innerHTML = ` +
Step ${idx + 1}
+ + +
+ ${getPatternButtonCodes().map(tok => ``).join('')} +
+
+ +
+ + +
+
+
${rowList}
+ + + + +
+ + + +
+ `; + card.querySelectorAll('input, textarea').forEach(el => { + el.addEventListener('input', (e) => { + const i = Number(e.target.dataset.idx); + const field = e.target.dataset.field; + if (!patternDraft.steps[i]) return; + patternDraft.steps[i][field] = e.target.value; + persistPatternDraft(); + }); + }); + card.querySelectorAll('.pattern-buttons button').forEach(btn => { + btn.addEventListener('click', () => { + const tok = btn.dataset.tok; + addPatternTokenToStep(idx, tok); + }); + }); + card.querySelectorAll('input[data-row]').forEach(inp => { + inp.addEventListener('input', (e) => { + const rowIdx = Number(e.target.dataset.row); + updateStepRow(idx, rowIdx, e.target.value); + }); + }); + const rowTextarea = card.querySelector('textarea[data-field="rowDraft"]'); + if (rowTextarea) { + rowTextarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + addStepRow(idx); + } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + addStep(); + } + }); + } + requestAnimationFrame(() => { + card.classList.remove('card-pop'); + }); + container.appendChild(card); + }); + const addRow = document.createElement('div'); + addRow.className = 'pattern-step-card add-step-row'; + addRow.innerHTML = ` + + `; + container.appendChild(addRow); +} + +function addStep() { + patternDraft.steps.push({ title: '', rows: [], rowDraft: '', note: '', image: '' }); + persistPatternDraft(); + renderSteps(); +} + +function addStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + const line = (step.rowDraft || '').trim(); + if (!line) return; + step.rows.push(line); + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function removeStepLastRow(idx) { + // no-op: last-row removal handled per-row delete +} + +function removeStepRow(idx, rowIdx) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; + step.rows.splice(rowIdx, 1); + persistPatternDraft(); + renderSteps(); +} + +function clearStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function updateStepRow(idx, rowIdx, value) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || rowIdx < 0 || rowIdx >= step.rows.length) return; + step.rows[rowIdx] = value; + persistPatternDraft(); +} + +function renderAbbrevChecklist() { + const listEl = document.getElementById('patternAbbrevList'); + if (!listEl) return; + listEl.innerHTML = ''; + const selected = new Set(patternDraft.abbrevSelection); + getAbbrevGroups().forEach(bucket => { + const wrap = document.createElement('details'); + wrap.className = 'abbrev-group'; + wrap.open = true; + const summary = document.createElement('summary'); + summary.textContent = `${bucket.label} (${bucket.items.length})`; + wrap.appendChild(summary); + const grid = document.createElement('div'); + grid.className = 'abbrev-grid'; + bucket.items.forEach(item => { + const pill = document.createElement('div'); + pill.className = 'abbrev-pill'; + if (selected.has(item.code)) pill.classList.add('is-selected'); + pill.dataset.code = item.code; + pill.innerHTML = ` + ${item.code} + ${item.desc} + `; + pill.addEventListener('click', () => { + const code = pill.dataset.code; + if (pill.classList.contains('is-selected')) { + pill.classList.remove('is-selected'); + patternDraft.abbrevSelection = patternDraft.abbrevSelection.filter(c => c !== code); + } else { + pill.classList.add('is-selected'); + if (!patternDraft.abbrevSelection.includes(code)) patternDraft.abbrevSelection.push(code); + } + updateAbbrevFromSelection(); + }); + grid.appendChild(pill); + }); + wrap.appendChild(grid); + listEl.appendChild(wrap); + }); + renderSelectedAbbrev(); +} + +function updateAbbrevFromSelection() { + const lines = patternDraft.abbrevSelection.map(code => { + const found = getAbbrevByCode(code); + return found ? `${found.code} – ${found.desc}` : code; + }); + patternDraft.abbrev = lines.join('\n'); + persistPatternDraft(); + const abbrevEl = document.getElementById('patternAbbrev'); + if (abbrevEl) abbrevEl.value = patternDraft.abbrev; + renderSelectedAbbrev(); + renderSteps(); +} + +function renderSelectedAbbrev() { + renderPatternButtons(); +} + +function renderPatternButtons() { + // Buttons are rendered per-step; no global buttons. +} + +function deleteStep(idx) { + patternDraft.steps.splice(idx, 1); + persistPatternDraft(); + renderSteps(); +} + +function moveStep(idx, dir) { + const target = idx + dir; + if (target < 0 || target >= patternDraft.steps.length) return; + const tmp = patternDraft.steps[idx]; + patternDraft.steps[idx] = patternDraft.steps[target]; + patternDraft.steps[target] = tmp; + persistPatternDraft(); + renderSteps(); +} + +function addPatternTokenToStep(idx, tok) { + const step = patternDraft.steps[idx]; + if (!step) return; + // Append with comma/space + if (!step.rowDraft) { + step.rowDraft = tok; + } else { + // If last token matches, increment count + const parts = step.rowDraft.split(',').map(s => s.trim()).filter(Boolean); + if (parts.length && !nonMergeTokens.has(tok)) { + const last = parts[parts.length - 1]; + const escapedTok = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = last.match(new RegExp(`^${escapedTok}(\\d+)$`, 'i')); + if (last.toLowerCase() === tok.toLowerCase()) { + parts[parts.length - 1] = `${tok}2`; + } else if (match) { + const n = parseInt(match[1], 10) + 1; + parts[parts.length - 1] = `${tok}${n}`; + } else { + parts.push(tok); + } + step.rowDraft = parts.join(', '); + } else { + parts.push(tok); + step.rowDraft = parts.join(', '); + } + } + persistPatternDraft(); + renderSteps(); +} + +function addStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + const line = (step.rowDraft || '').trim(); + if (!line) return; + step.rows.push(line); + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function removeStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step || !step.rows || step.rows.length === 0) return; + step.rows.pop(); + persistPatternDraft(); + renderSteps(); +} + +function clearStepRow(idx) { + const step = patternDraft.steps[idx]; + if (!step) return; + step.rowDraft = ''; + persistPatternDraft(); + renderSteps(); +} + +function openPatternComposer() { + if (!patternOverlay) return; + patternOverlay.classList.add('active'); + syncPatternUI(); + showPatternTab('steps'); +} + +function closePatternComposer() { + if (!patternOverlay) return; + patternOverlay.classList.remove('active'); +} +syncPatternUI(); +bindPatternInputs(); +renderAbbrevChecklist(); +let pendingSaveSelection = []; +let lastCountPulse = null; +let lastFinishedId = null; +let fireflyTimer = null; +let fireflyActive = false; +let titleClicks = []; +let easterEggCooling = false; + +// --- Service Worker --- +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => { + // fail silently; optional log + }); + }); +} + +// --- Install Prompt --- +let deferredInstallPrompt = null; +const isStandalone = () => + window.matchMedia('(display-mode: standalone)').matches || + window.navigator.standalone === true; + +function hideInstall() { + if (installBtn) installBtn.classList.add('hidden'); +} +function showInstall() { + if (installBtn) installBtn.classList.remove('hidden'); +} + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredInstallPrompt = e; + if (!isStandalone()) showInstall(); +}); + +window.addEventListener('appinstalled', () => { + deferredInstallPrompt = null; + hideInstall(); +}); + +if (installBtn) { + installBtn.addEventListener('click', async () => { + if (!deferredInstallPrompt) return; + deferredInstallPrompt.prompt(); + const choice = await deferredInstallPrompt.userChoice; + if (choice.outcome === 'accepted') hideInstall(); + deferredInstallPrompt = null; + }); +} + +if (isStandalone()) hideInstall(); + +// --- Sweet-ish Alerts --- +function removeSwal() { + const existing = document.querySelector('.swal-overlay'); + if (existing) existing.remove(); +} + +function showConfirm({ title = 'Are you sure?', text = '', confirmText = 'Yes', cancelText = 'Cancel', danger = false } = {}) { + return new Promise(resolve => { + removeSwal(); + const overlay = document.createElement('div'); + overlay.className = 'swal-overlay'; + overlay.innerHTML = ` +
+
${title}
+
${text}
+
+ + +
+
+ `; + const cancelBtn = overlay.querySelector('.swal-cancel'); + const confirmBtn = overlay.querySelector('.swal-confirm, .swal-danger'); + cancelBtn.onclick = () => { removeSwal(); resolve(false); }; + confirmBtn.onclick = () => { removeSwal(); resolve(true); }; + overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(false); } }); + document.addEventListener('keydown', function onKey(e) { + if (e.key === 'Escape') { removeSwal(); resolve(false); document.removeEventListener('keydown', onKey); } + }); + document.body.appendChild(overlay); + }); +} + +function showAlert({ title = 'Notice', text = '' } = {}) { + return new Promise(resolve => { + removeSwal(); + const overlay = document.createElement('div'); + overlay.className = 'swal-overlay'; + overlay.innerHTML = ` +
+
${title}
+
${text}
+
+ +
+
+ `; + const okBtn = overlay.querySelector('.swal-confirm'); + okBtn.onclick = () => { removeSwal(); resolve(); }; + overlay.addEventListener('click', (e) => { if (e.target === overlay) { removeSwal(); resolve(); } }); + document.addEventListener('keydown', function onKey(e) { + if (e.key === 'Escape') { removeSwal(); resolve(); document.removeEventListener('keydown', onKey); } + }); + document.body.appendChild(overlay); + }); +} + +// --- Theme Logic --- +let isDarkMode = JSON.parse(localStorage.getItem('crochetDarkMode')); +if (isDarkMode === null) { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + isDarkMode = true; + } else { + isDarkMode = false; + } +} +let animationsEnabled = JSON.parse(localStorage.getItem('crochetAnimations')); +if (animationsEnabled === null) animationsEnabled = true; + +function applyTheme() { + if (isDarkMode) { + document.body.classList.add('dark-mode'); + document.getElementById('themeBtn').innerHTML = ''; + } else { + document.body.classList.remove('dark-mode'); + document.getElementById('themeBtn').innerHTML = ''; + } + handleAmbientDrift(); + updateMotionBtn(); +} + +function toggleTheme() { + isDarkMode = !isDarkMode; + localStorage.setItem('crochetDarkMode', isDarkMode); + applyTheme(); + if (animationsEnabled) { + document.body.classList.add('theme-animating'); + setTimeout(() => document.body.classList.remove('theme-animating'), 750); + } +} +applyTheme(); + +function updateMotionBtn() { + if (!motionBtn) return; + motionBtn.innerHTML = animationsEnabled ? '' : ''; + motionBtn.title = animationsEnabled ? 'Toggle Animations' : 'Animations disabled'; +} + +function toggleAnimations() { + animationsEnabled = !animationsEnabled; + localStorage.setItem('crochetAnimations', animationsEnabled); + updateMotionBtn(); + if (!animationsEnabled) { + stopAmbientDrift(); + document.body.classList.remove('theme-animating'); + } else { + handleAmbientDrift(); + } +} + +function openColorPicker(pId, partId) { + if (!colorOverlay || !colorGrid) return; + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + colorGrid.innerHTML = colors.map(c => ` + + `).join(''); + if (customColorInput) { + customColorInput.value = part.color || project.color || colors[0]; + customColorInput.oninput = (e) => { + setPartColor(pId, partId, e.target.value); + }; + } + colorOverlay.classList.add('active'); + colorOverlay.dataset.projectId = pId; + colorOverlay.dataset.partId = partId; +} + +function closeColorPicker() { + if (!colorOverlay) return; + colorOverlay.classList.remove('active'); + colorGrid.innerHTML = ''; + colorOverlay.dataset.projectId = ''; + colorOverlay.dataset.partId = ''; +} + +function savePatterns() { + localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); +} + +function populatePatternSelect() { + if (!patternPicker || !patternSelect) return; + const hasPatterns = patterns.length > 0; + patternPicker.style.display = hasPatterns ? 'block' : 'none'; + patternSelect.innerHTML = '' + patterns.map(p => ``).join(''); +} + +function exportData(selectedProjects = projects) { + const payload = { + projects: selectedProjects, + isDarkMode, + animationsEnabled, + patterns + }; + const names = selectedProjects.map(p => p.name || 'Project').join('_').replace(/\s+/g, '-').slice(0, 50) || 'projects'; + const filename = `toadstool_${names}.json`; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +function triggerImport() { + if (!importInput) return; + importInput.value = ''; + importInput.click(); +} + +async function handleImport(event) { + const file = event.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const data = JSON.parse(text); + if (!data.projects || !Array.isArray(data.projects)) throw new Error('Invalid file'); + projects = data.projects; + if (typeof data.isDarkMode === 'boolean') { + isDarkMode = data.isDarkMode; + localStorage.setItem('crochetDarkMode', isDarkMode); + } + if (typeof data.animationsEnabled === 'boolean') { + animationsEnabled = data.animationsEnabled; + localStorage.setItem('crochetAnimations', animationsEnabled); + } + if (Array.isArray(data.patterns)) { + patterns = data.patterns; + localStorage.setItem('crochetPatterns', JSON.stringify(patterns)); + } + localStorage.setItem('crochetCounters', JSON.stringify(projects)); + applyTheme(); + render(); + } catch (err) { + showAlert({ title: 'Import failed', text: err.message }); + } + event.target.value = ''; +} + +if (importInput) { + importInput.addEventListener('change', handleImport); +} + +function openSaveModal() { + if (!saveOverlay || !saveList) return; + saveList.innerHTML = ''; + pendingSaveSelection = projects.map(p => p.id); + projects.forEach(p => { + const item = document.createElement('label'); + item.className = 'save-item'; + item.innerHTML = ` + + ${p.name} + `; + saveList.appendChild(item); + }); + saveOverlay.classList.add('active'); +} + +function closeSaveModal() { + if (!saveOverlay) return; + saveOverlay.classList.remove('active'); + saveList.innerHTML = ''; +} + +function exportSelected() { + if (!saveOverlay) return; + const inputs = saveList.querySelectorAll('input[type="checkbox"]'); + const selectedIds = Array.from(inputs).filter(i => i.checked).map(i => Number(i.dataset.id)); + if (selectedIds.length === 0) { closeSaveModal(); return; } + const selectedProjects = projects.filter(p => selectedIds.includes(p.id)); + exportData(selectedProjects); + closeSaveModal(); +} + +// --- Firefly Animation --- +function spawnFirefly({ markActive = false, source = 'ambient', side = 'any' } = {}) { + const wrap = document.createElement('div'); + wrap.className = 'firefly-wrap'; + const el = document.createElement('div'); + el.className = 'firefly'; + const top = Math.random() * 55 + 5; // 5vh–60vh + const scale = 0.9 + Math.random() * 0.4; + const duration = 12 + Math.random() * 8; // 12–20s + const chosenSide = side === 'any' ? ['left','right','top','bottom'][Math.floor(Math.random()*4)] : side; + let startX = '-10vw', endX = '110vw', startY = `${top}vh`, endY = `${top + (Math.random()*12 - 6)}vh`; + let midX = '25vw', midY = `${top - 6}vh`, mid2X = '65vw', mid2Y = `${top + 6}vh`; + if (chosenSide === 'right') { + startX = '110vw'; endX = '-10vw'; + midX = '-25vw'; mid2X = '-65vw'; + } else if (chosenSide === 'top') { + const x = Math.random()*80 + 10; + startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`; + startY = '-12vh'; endY = '110vh'; + midX = `${x + 8}vw`; mid2X = `${x - 8}vw`; + midY = '25vh'; mid2Y = '65vh'; + } else if (chosenSide === 'bottom') { + const x = Math.random()*80 + 10; + startX = `${x}vw`; endX = `${x + (Math.random()*20 - 10)}vw`; + startY = '110vh'; endY = '-12vh'; + midX = `${x - 8}vw`; mid2X = `${x + 8}vw`; + midY = '75vh'; mid2Y = '35vh'; + } + wrap.style.setProperty('--fly-scale', scale); + wrap.style.setProperty('--fly-duration', `${duration}s`); + wrap.style.setProperty('--fly-start-x', startX); + wrap.style.setProperty('--fly-start-y', startY); + wrap.style.setProperty('--fly-mid-x', midX); + wrap.style.setProperty('--fly-mid-y', midY); + wrap.style.setProperty('--fly-mid2-x', mid2X); + wrap.style.setProperty('--fly-mid2-y', mid2Y); + wrap.style.setProperty('--fly-end-x', endX); + wrap.style.setProperty('--fly-end-y', endY); + if (markActive) fireflyActive = true; + wrap.addEventListener('animationend', (e) => { + if (e.animationName !== 'fireflyGlide') return; + wrap.remove(); + if (markActive) fireflyActive = false; + }); + wrap.appendChild(el); + document.body.appendChild(wrap); +} + +function spawnSeed({ markActive = false, source = 'ambient' } = {}) { + const wrap = document.createElement('div'); + wrap.className = 'seed-wrap'; + const el = document.createElement('div'); + el.className = 'seed'; + const top = Math.random() * 55 + 5; // 5vh–60vh + const scale = 0.85 + Math.random() * 0.4; + const duration = 14 + Math.random() * 8; // 14–22s + const tilt = (Math.random() * 16 + 8) * (Math.random() < 0.5 ? -1 : 1); // +/-8–24deg + const sway = 4 + Math.random() * 6; // px + const flipDur = 5 + Math.random() * 4; // 5–9s + const dir = ['left','right','top'][Math.floor(Math.random()*3)]; + const fromLeft = dir === 'left'; + let start = fromLeft ? '-12vw' : '112vw'; + let mid = fromLeft ? '30vw' : '-30vw'; + let end = fromLeft ? '112vw' : '-12vw'; + if (dir === 'top') { + const x = Math.random()*80 + 10; + start = `${x}vw`; + mid = `${x + (Math.random()*10 - 5)}vw`; + end = `${x + (Math.random()*20 - 10)}vw`; + wrap.style.top = '-12vh'; + } else { + wrap.style.top = `${top}vh`; + } + wrap.style.setProperty('--seed-scale', scale); + wrap.style.setProperty('--seed-duration', `${duration}s`); + wrap.style.setProperty('--seed-tilt', `${tilt}deg`); + wrap.style.setProperty('--seed-sway', `${sway}px`); + wrap.style.setProperty('--seed-flip-duration', `${flipDur}s`); + wrap.style.setProperty('--seed-start', start); + wrap.style.setProperty('--seed-mid', mid); + wrap.style.setProperty('--seed-end', end); + if (markActive) fireflyActive = true; + wrap.addEventListener('animationend', (e) => { + if (e.animationName !== 'seedGlide') return; + wrap.remove(); + if (markActive) fireflyActive = false; + }); + wrap.appendChild(el); + document.body.appendChild(wrap); +} + +function stopAmbientDrift() { + if (fireflyTimer) { + clearTimeout(fireflyTimer); + fireflyTimer = null; + } + document.querySelectorAll('.firefly-wrap').forEach(el => el.remove()); + document.querySelectorAll('.seed-wrap').forEach(el => el.remove()); + fireflyActive = false; +} + +function scheduleAmbientDrift() { + const delay = 10000 + Math.random() * 10000; // 10–20s + fireflyTimer = setTimeout(() => { + if (!animationsEnabled) { stopAmbientDrift(); return; } + const selector = isDarkMode ? '.firefly-wrap' : '.seed-wrap'; + let existing = document.querySelectorAll(selector).length; + if (existing === 0) { + isDarkMode ? spawnFirefly() : spawnSeed(); + existing++; + } else if (existing < 5) { + isDarkMode ? spawnFirefly() : spawnSeed(); + } + scheduleAmbientDrift(); + }, delay); +} + +function handleAmbientDrift() { + stopAmbientDrift(); + if (!animationsEnabled) return; + if (isDarkMode) { + spawnFirefly(); + } else { + spawnSeed(); + } + scheduleAmbientDrift(); +} +handleAmbientDrift(); + +const logoIcon = document.querySelector('.brand-icon'); +if (logoIcon) { + logoIcon.addEventListener('click', () => { + if (!animationsEnabled || fireflyActive) return; + if (isDarkMode) { + spawnFirefly({ markActive: true, source: 'logo', side: 'any' }); + } else { + spawnSeed({ markActive: true, source: 'logo' }); + } + }); +} +if (colorOverlay) { + colorOverlay.addEventListener('click', (e) => { + if (e.target === colorOverlay) closeColorPicker(); + }); +} +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && colorOverlay && colorOverlay.classList.contains('active')) { + closeColorPicker(); + } +}); +const importBtn = document.getElementById('importBtn'); +if (importBtn) { + importBtn.addEventListener('click', triggerImport); +} + +const titleEl = document.getElementById('appTitle'); +if (titleEl) { + titleEl.addEventListener('click', () => { + const now = Date.now(); + titleClicks = titleClicks.filter(ts => now - ts < 7000); + titleClicks.push(now); + if (titleClicks.length >= 5 && !easterEggCooling) { + easterEggCooling = true; + triggerBurst(); + setTimeout(() => { + easterEggCooling = false; + titleClicks = []; + }, 8000); + } + }); +} + +function triggerBurst() { + if (!animationsEnabled) return; + const burstCount = isDarkMode ? 24 : 18; + const spawner = isDarkMode ? (opts) => spawnFirefly({ ...opts, side: 'any' }) : spawnSeed; + for (let i = 0; i < burstCount; i++) { + const jitter = Math.random() * 200; + setTimeout(() => spawner({ source: 'burst' }), i * 140 + jitter); + } +} + +// --- Focus Mode Logic --- +let wakeLock = null; +let isFocusMode = false; +const focusBtn = document.getElementById('focusBtn'); + +// --- Migration Check --- +if (projects.length > 0) { + let changed = false; + projects.forEach((p, index) => { + if (!p.parts) { p.parts = []; changed = true; } + if (!p.color) { p.color = colors[index % colors.length]; changed = true; } + const oldIdx = oldColors.indexOf(p.color); + if (oldIdx !== -1) { p.color = colors[oldIdx % colors.length]; changed = true; } + if (p.note === undefined) { p.note = ''; changed = true; } + p.parts.forEach(pt => { + if (pt.max === undefined) { pt.max = null; changed = true; } + if (pt.note === undefined) { pt.note = ''; changed = true; } + if (!pt.color) { pt.color = p.color; changed = true; } + const oldPartIdx = oldColors.indexOf(pt.color); + if (oldPartIdx !== -1) { pt.color = colors[oldPartIdx % colors.length]; changed = true; } + }); + }); + if (changed) { localStorage.setItem('crochetCounters', JSON.stringify(projects)); } +} + +// --- Core Functions --- +function save() { + localStorage.setItem('crochetCounters', JSON.stringify(projects)); + render(); +} + + async function toggleFocusMode() { + if (!isFocusMode) { + try { + if (document.documentElement.requestFullscreen) await document.documentElement.requestFullscreen(); + if ('wakeLock' in navigator) { + wakeLock = await navigator.wakeLock.request('screen'); + } + isFocusMode = true; + focusBtn.classList.add('is-active'); + } catch (err) { showAlert({ title: 'Focus Mode failed', text: err.message }); } + } else { + if (document.fullscreenElement) document.exitFullscreen(); + if (wakeLock !== null) { wakeLock.release(); wakeLock = null; } + isFocusMode = false; + focusBtn.classList.remove('is-active'); + } + } +document.addEventListener('visibilitychange', async () => { + if (wakeLock !== null && document.visibilityState === 'visible') { + wakeLock = await navigator.wakeLock.request('screen'); + } +}); + +// --- Interaction Logic --- + async function deleteProject(pId) { + const ok = await showConfirm({ title: 'Delete project?', text: 'This will remove the entire project.', confirmText: 'Delete', danger: true }); + if (ok) { projects = projects.filter(p => p.id !== pId); save(); } + } + function toggleProjectCollapse(pId) { + const project = projects.find(p => p.id === pId); + project.collapsed = !project.collapsed; + save(); + } + function renameProject(pId) { + modalState = { type: 'renameProject', pId, partId: null }; + const project = projects.find(p => p.id === pId); + modalTitle.innerText = "Rename Project"; + modalInput.value = project.name; + modalInput.type = "text"; + modalInput.placeholder = "Project name"; + modal.classList.add('active'); + setTimeout(() => modalInput.focus(), 100); + } + async function deletePart(pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + if (part.locked) return; + const ok = await showConfirm({ title: 'Delete part?', text: 'This part will be removed.', confirmText: 'Delete', danger: true }); + if (ok) { + project.parts = project.parts.filter(pt => pt.id !== partId); + save(); + } + } + function togglePartMinimize(pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + part.minimized = !part.minimized; + save(); + } + function togglePartLock(pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + part.locked = !part.locked; + save(); + } + function setPartColor(pId, partId, color) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + part.color = color; + save(); + } + function togglePartFinish(pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + part.finished = !part.finished; + if(part.finished) { part.locked = false; lastFinishedId = part.id; } else { lastFinishedId = null; } + save(); + } + function updateCount(pId, partId, change) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + if (part.locked || part.finished) return; + part.count += change; + if (part.max !== null && part.count > part.max) part.count = part.max; + if (part.count < 0) part.count = 0; + hapticTick(); + lastCountPulse = { partId, dir: change > 0 ? 'up' : 'down' }; + save(); + } +async function resetCount(pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + if (part.locked || part.finished) return; + const ok = await showConfirm({ title: 'Reset count?', text: 'Set this count back to zero.', confirmText: 'Reset', danger: true }); + if(ok) { + part.count = 0; + save(); + } +} + +function saveProjectAsPattern(pId) { + const project = projects.find(p => p.id === pId); + if (!project) return; + openModal('savePattern', pId); +} + +// --- Modal Logic --- +function openModal(type, pId = null, partId = null) { + modalState = { type, pId, partId }; + modalInput.value = ""; + + if (type === 'addProject') { + modalTitle.innerText = "New Project Name"; + modalInput.type = "text"; modalInput.placeholder = "e.g., Amigurumi Bear"; + if (patternPicker && patternSelect) { + populatePatternSelect(); + patternSelect.value = ''; + } + } else if (type === 'addPart') { + modalTitle.innerText = "New Part Name"; + modalInput.type = "text"; modalInput.placeholder = "e.g., Head"; + } else if (type === 'setMax') { + const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); + modalTitle.innerText = "Set Max Stitches"; + modalInput.value = part.max ?? ''; + modalInput.type = "number"; + modalInput.placeholder = "Leave blank to clear"; + } else if (type === 'renamePart') { + const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); + if(part.locked || part.finished) return; + modalTitle.innerText = "Rename Part"; + modalInput.value = part.name; modalInput.type = "text"; + } else if (type === 'savePattern') { + modalTitle.innerText = "Save as Pattern"; + const project = projects.find(p => p.id === pId); + modalInput.value = project ? `${project.name} pattern` : ''; + modalInput.type = "text"; modalInput.placeholder = "Pattern name"; + } else if (type === 'manualCount') { + const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); + if(part.locked || part.finished) return; + modalTitle.innerText = "Set Row Count"; + modalInput.value = part.count; modalInput.type = "number"; + } + else if (type === 'setMax') { + const part = projects.find(p => p.id === pId).parts.find(pt => pt.id === partId); + if (part.locked) return; + } + if (type !== 'addProject' && patternPicker) { + patternPicker.style.display = 'none'; + } + modal.classList.add('active'); + setTimeout(() => modalInput.focus(), 100); +} +function closeModal() { + modal.classList.remove('active'); + modalInput.blur(); +} + function saveModal() { + const val = modalInput.value.trim(); + if (!val && modalState.type !== 'manualCount' && modalState.type !== 'setMax') return closeModal(); + + if (modalState.type === 'addProject') { + const nextColor = colors[projects.length % colors.length]; + const newProject = { id: Date.now(), name: val, color: nextColor, collapsed: false, note: '', parts: [] }; + const selectedPatternId = patternSelect ? patternSelect.value : ''; + const chosenPattern = selectedPatternId ? patterns.find(p => String(p.id) === selectedPatternId) : null; + if (chosenPattern && Array.isArray(chosenPattern.parts) && chosenPattern.parts.length) { + chosenPattern.parts.forEach((pt, idx) => { + newProject.parts.push({ + id: Date.now() + idx + 1, + name: pt.name || `Part ${idx + 1}`, + count: 0, + locked: false, + finished: false, + minimized: false, + max: pt.max ?? null, + color: pt.color || newProject.color, + note: '' + }); + }); + } else { + newProject.parts.push({ id: Date.now() + 1, name: 'Part 1', count: 0, locked: false, finished: false, minimized: false, max: null, color: nextColor, note: '' }); + } + projects.push(newProject); + } + else if (modalState.type === 'addPart') { + const project = projects.find(p => p.id === modalState.pId); + project.parts.push({ id: Date.now(), name: val, count: 0, locked: false, finished: false, minimized: false, max: null, color: project.color, note: '' }); + project.collapsed = false; + } + else if (modalState.type === 'renamePart') { + const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); + part.name = val; + } + else if (modalState.type === 'renameProject') { + const project = projects.find(p => p.id === modalState.pId); + project.name = val; + } + else if (modalState.type === 'manualCount') { + const num = parseInt(val); + if (!isNaN(num) && num >= 0) { + const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); + part.count = num; + if (part.max !== null && part.count > part.max) part.count = part.max; + } + } + else if (modalState.type === 'setMax') { + const part = projects.find(p => p.id === modalState.pId).parts.find(pt => pt.id === modalState.partId); + if (val === '') { + part.max = null; + } else { + const num = parseInt(val); + if (!isNaN(num) && num > 0) { + part.max = num; + if (part.count > part.max) part.count = part.max; + } + } + } + else if (modalState.type === 'savePattern') { + const project = projects.find(p => p.id === modalState.pId); + if (project) { + const template = project.parts.map(pt => ({ name: pt.name, color: pt.color, max: pt.max })); + patterns.push({ id: Date.now(), name: val || project.name, color: project.color, parts: template }); + savePatterns(); + populatePatternSelect(); + } + } + save(); + closeModal(); +} + modalInput.addEventListener("keyup", (e) => { if (e.key === "Enter") saveModal(); }); + + function toggleNote(id) { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle('show'); + } + + function updateProjectNote(e, pId) { + const project = projects.find(p => p.id === pId); + project.note = e.target.value; + localStorage.setItem('crochetCounters', JSON.stringify(projects)); + } + + function updatePartNote(e, pId, partId) { + const project = projects.find(p => p.id === pId); + const part = project.parts.find(pt => pt.id === partId); + part.note = e.target.value; + localStorage.setItem('crochetCounters', JSON.stringify(projects)); + } + +// --- Render Logic --- +function render() { + app.innerHTML = ''; + if (projects.length === 0) { + app.innerHTML = '
Toadstools & twine await...
Tap + to begin a new project.
'; + return; + } + + const grid = document.createElement('div'); + grid.className = 'projects-grid'; + + projects.forEach(project => { + const sortedParts = [...project.parts].sort((a, b) => a.finished - b.finished); + const projectCollapsedClass = project.collapsed ? 'project-collapsed' : ''; + + let partsHtml = ''; + sortedParts.forEach(part => { + const accent = part.color || project.color; + const isLocked = part.locked ? 'is-locked' : ''; + const isFinished = part.finished ? 'is-finished' : ''; + const isMinimized = part.minimized ? 'is-minimized' : ''; + const lockIcon = part.locked ? '' : ''; + const lockBtnClass = part.locked ? 'btn-lock locked-active' : 'btn-lock'; + const controlsDimmed = (part.locked || part.finished) ? 'dimmed' : ''; + const hideControls = (part.finished || part.minimized) ? 'hidden-controls' : ''; + const showSetMax = part.minimized ? 'hidden' : ''; + const partNoteId = `part-note-${project.id}-${part.id}`; + const countId = `count-${part.id}`; + const pulseClass = lastCountPulse && lastCountPulse.partId === part.id + ? (lastCountPulse.dir === 'up' ? 'count-bump-up' : 'count-bump-down') + : ''; + const finishPulseClass = part.finished && lastFinishedId === part.id ? 'finish-shimmer' : ''; + const partCardId = `part-${part.id}`; + const partCardFullClass = `${isLocked} ${isFinished} ${isMinimized} ${finishPulseClass}`; + const lockDisabled = part.locked ? 'disabled' : ''; + const actionsHtml = part.minimized + ? `
` + : `
+ + + + +
`; + const countSubtext = part.minimized ? '' : ` +
+ ${part.max !== null ? `${part.count} / ${part.max}` : 'No max set'} + +
+ `; + + partsHtml += ` +
+
+
+ + ${part.name} + ${part.count} +
+ ${actionsHtml} +
+
${part.count}
+ ${countSubtext} +
+ + + +
+
+ +
+ +
`; + }); + + const projectContainer = document.createElement('div'); + projectContainer.className = `project-container ${projectCollapsedClass}`; + projectContainer.style = `--project-color: ${project.color}`; + const projectNoteId = `project-note-${project.id}`; + projectContainer.innerHTML = ` +
+
+ + ${project.name} + +
+
+ + + +
+
+
+ +
+ +
${partsHtml}
+ `; + grid.appendChild(projectContainer); + }); + + lastCountPulse = null; + lastFinishedId = null; + app.appendChild(grid); +} + +render(); +function flashSave() { + const el = document.getElementById('patternSaveIndicator'); + if (!el) return; + el.textContent = 'Saved'; + el.style.opacity = '1'; + if (saveFlashTimer) clearTimeout(saveFlashTimer); + saveFlashTimer = setTimeout(() => { + el.style.opacity = '0.6'; + }, 1200); +} diff --git a/assets/style.css b/assets/style.css index 966a10c..4e6f226 100644 --- a/assets/style.css +++ b/assets/style.css @@ -54,7 +54,7 @@ body.dark-mode { * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } body { - font-family: 'Quicksand', "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-family: 'Nunito', "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: var(--bg); min-height: 100vh; color: var(--text); @@ -97,7 +97,7 @@ h1 { letter-spacing: 0.5px; color: var(--header-text); text-shadow: 1px 1px 2px rgba(0,0,0,0.1); - font-family: 'Cormorant Garamond', Georgia, serif; + font-family: 'Playfair Display', Georgia, serif; cursor: pointer; user-select: none; } @@ -135,6 +135,189 @@ h1 { .header-btn.is-active { background: var(--header-text); color: var(--header-bg); } .hidden { display: none !important; } .hidden-input { display: none; } + +.fab-pattern { right: auto; left: 22px; background: var(--text); } +.fab-pattern:hover { transform: scale(1.07); } + +/* Pattern Composer Overlay */ +.pattern-overlay { + position: fixed; + inset: 0; + background: rgba(44, 35, 25, 0.55); + backdrop-filter: blur(2px); + display: none; + align-items: center; + justify-content: center; + z-index: 215; + padding: 14px; +} +.pattern-overlay.active { display: flex; } +.pattern-sheet { + width: min(1280px, 96vw); + max-height: 92vh; + overflow: auto; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + box-shadow: 0 16px 40px rgba(0,0,0,0.25); +} +.pattern-sheet-header { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 10px; + align-items: center; + margin-bottom: 12px; +} +.pattern-sheet-title h2 { margin: 0; font-size: 1.2rem; color: var(--text); } +.pattern-sheet-subtitle { margin: 2px 0 0; color: var(--text-muted); font-size: 0.9rem; } +.pattern-sheet-header h2 { margin: 0; font-size: 1.2rem; color: var(--text); } +.pattern-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-muted); + cursor: pointer; +} +.pattern-close:hover { color: var(--project-color); } +.pattern-modes { display: flex; gap: 8px; justify-content: flex-end; } +.pattern-mode { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 6px 10px; border-radius: 10px; cursor: pointer; } +.pattern-mode.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); } +.pattern-save-indicator { color: var(--text-muted); font-size: 0.9rem; } +.pattern-body { display: grid; gap: 12px; } +.pattern-row-info { color: var(--text-muted); font-size: 0.95rem; } +.pattern-buttons { display: flex; flex-wrap: wrap; gap: 6px; } +.pattern-buttons button { padding: 6px 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); cursor: pointer; color: #fff; } +.pattern-buttons button:hover { border-color: var(--project-color); color: var(--project-color); } +.pattern-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.pattern-tab { + background: var(--input-bg); + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; + color: var(--text); +} +.pattern-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); } +.pattern-section { display: none; border: 1px dashed var(--border); padding: 12px; border-radius: 12px; background: var(--card-bg); } +.pattern-section.is-active { display: block; } +.field-label { display: block; margin: 8px 0 4px; color: var(--text-muted); font-size: 0.9rem; } +.pattern-section input, .pattern-section textarea { + width: 100%; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + padding: 10px; + font-family: inherit; +} +.field-group-inline { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: center; } +.pattern-row-editor textarea, +.pattern-output textarea { + width: 100%; + min-height: 60px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: #fff; + padding: 10px; + font-family: inherit; +} +.pattern-row-editor { display: grid; gap: 8px; } +.pattern-row-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } +.pattern-row-actions .primary { background: var(--project-color); color: var(--card-bg); border: none; padding: 8px 12px; border-radius: 10px; cursor: pointer; } +.pattern-row-actions .secondary { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); padding: 8px 12px; border-radius: 10px; cursor: pointer; } +.pattern-footer { justify-content: space-between; border-top: 1px solid var(--border); padding-top: 8px; } +.pattern-output { display: grid; gap: 6px; } +.pattern-steps-head { display: flex; align-items: center; justify-content: space-between; } +.pattern-step-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + margin-bottom: 10px; + background: var(--card-bg); + transition: box-shadow 0.2s ease, transform 0.18s ease; +} +.pattern-step-card.card-pop { animation: cardPop 0.28s ease; } +.pattern-step-card:hover { box-shadow: 0 8px 18px rgba(0,0,0,0.08); transform: translateY(-1px); } +.pattern-row-list { display: grid; gap: 6px; } +.row-item { display: grid; grid-template-columns: auto 1fr auto; gap: 6px; align-items: center; } +.row-item label { color: var(--text-muted); font-size: 0.9rem; } +.row-item input { + width: 100%; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); +} +.row-item button { border: 1px solid var(--border); background: var(--btn-secondary-bg); color: var(--text); border-radius: 8px; padding: 4px 8px; cursor: pointer; } +.pattern-step-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; } +.add-step-row { display: flex; justify-content: center; padding: 10px; border: 1px dashed var(--border); background: var(--card-bg); } +.add-step-row .add-step-btn { padding: 10px 16px; border-radius: 12px; border: none; background: var(--project-color); color: var(--card-bg); cursor: pointer; font-weight: 700; } +.add-step-row .add-step-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.12); } +.add-step-row .add-step-btn:active { transform: translateY(0); box-shadow: none; } +.step-number { + font-weight: 800; + color: var(--project-color); + margin-bottom: 4px; + font-size: 1rem; +} +@keyframes cardPop { + 0% { transform: translateY(6px) scale(0.97); opacity: 0; } + 60% { transform: translateY(-2px) scale(1.01); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} +.abbrev-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.abbrev-group { border: 1px solid var(--border); border-radius: 12px; margin: 6px 0; background: var(--card-bg); } +.abbrev-group[open] > summary { border-bottom: 1px solid var(--border); } +.abbrev-group summary { + cursor: pointer; + padding: 8px 10px; + font-weight: 700; + color: var(--text); + list-style: none; +} +.abbrev-group summary::-webkit-details-marker { display: none; } +.abbrev-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 6px; + padding: 8px 10px 10px; +} +.abbrev-pill { + display: flex; + flex-direction: column; + gap: 4px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-size: 0.92rem; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; +} +.abbrev-pill:hover { transform: translateY(-1px); border-color: var(--project-color); } +.abbrev-pill.is-selected { + background: var(--project-color); + border-color: var(--project-color); + color: var(--card-bg); +} +.abbrev-pill .code { font-weight: 700; color: inherit; } +.abbrev-pill .desc { color: inherit; opacity: 0.85; font-size: 0.85rem; } +.selected-abbrev { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 10px; +} +.selected-abbrev .pill { + background: var(--project-color); + color: var(--card-bg); + padding: 4px 8px; + border-radius: 10px; + font-size: 0.85rem; +} .color-overlay { position: fixed; inset: 0; @@ -156,7 +339,7 @@ h1 { box-shadow: 0 10px 30px rgba(0,0,0,0.25); border: 1px solid var(--border); } -.color-title { margin: 0 0 12px; font-family: 'Cormorant Garamond', Georgia, serif; } +.color-title { margin: 0 0 12px; font-family: 'Playfair Display', Georgia, serif; } .color-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); @@ -277,7 +460,7 @@ h1 { .project-title { font-size: 1.4rem; font-weight: 800; color: var(--project-color); text-transform: uppercase; letter-spacing: 1px; - font-family: 'Cormorant Garamond', Georgia, serif; + font-family: 'Playfair Display', Georgia, serif; } .btn-toggle-project { @@ -686,7 +869,7 @@ button:active { transform: scale(0.97); box-shadow: none; } } .swal-title { - font-family: 'Cormorant Garamond', Georgia, serif; + font-family: 'Playfair Display', Georgia, serif; font-size: 1.4rem; margin: 0 0 8px; } diff --git a/assets/style.min.css b/assets/style.min.css index 2947a09..4e6f226 100644 --- a/assets/style.min.css +++ b/assets/style.min.css @@ -1 +1,899 @@ -root--bg#e6e0d0--card-bg#f9f4e6--header-bg#8a6b52--header-text#fdf8f0--text#433628--text-muted#7a6d5c--border#d6cabc--shadow0 4px 10px rgba(6754400.12)--seed-opacity0.06--modal-bg#f8f5e6--input-bg#fffdf5--input-border#d1c7b7--bg-finished#dbe4ce--lock-btn-bg#ebe7da--lock-btn-text#9c8e7e--btn-secondary-bg#fdfcf5--btn-secondary-text#4a3b2a--danger#b56b54--success#7a8b4f--project-color#b56b54body.dark-mode--bg#2c3327--card-bg#3b342c--header-bg#4b3829--header-text#e9e4d7--text#e9e4d7--text-muted#b5aa9b--border#5f5245--shadow0 4px 10px rgba(0000.45)--modal-bg#3d362d--input-bg#2c2720--input-border#594e3f--bg-finished#2a382a--lock-btn-bg#4a4238--lock-btn-text#8c8276--btn-secondary-bg#4a4238--btn-secondary-text#d1c7b7*box-sizingborder-box-webkit-tap-highlight-colortransparentbodyfont-family'Quicksand'"Segoe UI"RobotoHelveticaArialsans-serifbackground-colorvar(--bg)min-height100vhcolorvar(--text)margin0padding0displayflexflex-directioncolumnoverflow-xhiddenscrollbar-gutterstabletransitionbackground-color 0.3scolor 0.3sbody.theme-animatinganimationthemeSwap 0.7s ease-in-out@keyframes themeSwap0%filterbrightness(1.08) saturate(1.06)opacity0.8545%filterbrightness(1.03) saturate(1.12) contrast(1.04)opacity0.9275%filterbrightness(0.98) saturate(1.04)opacity0.97100%filterbrightness(1) saturate(1)opacity1headerbackground-colorvar(--header-bg)colorvar(--header-text)padding0.8rem 1remdisplayflexjustify-contentspace-betweenalign-itemscenterbox-shadow0 2px 10px rgba(0000.1)positionstickytop0z-index100border-bottom2px solid rgba(0000.05)transitionbackground-color 0.3sh1margin0font-size1.3remfont-weight800letter-spacing0.5pxcolorvar(--header-text)text-shadow1px 1px 2px rgba(0000.1)font-family'Cormorant Garamond'Georgiaserifcursorpointeruser-selectnone.branddisplayflexalign-itemscentergap10px.brand-iconwidth52pxheight52pxborder-radius8pxobject-fitcontaindisplayblockbackgroundrgba(2552552550.12)padding4pxbox-shadowinset 0 1px 2px rgba(0000.08).header-controlsdisplayflexgap10px.header-btnbackgroundrgba(2552552550.2)bordernoneborder-radius50%width40pxheight40pxdisplayflexalign-itemscenterjustify-contentcenterfont-size1.2remcursorpointercolorvar(--header-text)transitiontransform 0.18s easebox-shadow 0.18s easebackground-color 0.2stransformtranslateY(0)font-familyinherit.header-btnhovertransformtranslateY(-1px) scale(1.03)box-shadow0 6px 14px rgba(0000.12).header-btnactivetransformtranslateY(0) scale(0.96)box-shadownone.header-btn.is-activebackgroundvar(--header-text)colorvar(--header-bg).hiddendisplaynone !important.hidden-inputdisplaynone.color-overlaypositionfixedinset0backgroundrgba(4435250.55)displaynonealign-itemscenterjustify-contentcenterz-index210padding20pxbackdrop-filterblur(2px).color-overlay.activedisplayflex.color-modalbackgroundvar(--card-bg)colorvar(--text)border-radius16pxpadding18px 18px 14pxwidthmin(420px90vw)box-shadow0 10px 30px rgba(0000.25)border1px solid var(--border).color-titlemargin0 0 12pxfont-family'Cormorant Garamond'Georgiaserif.color-griddisplaygridgrid-template-columnsrepeat(auto-fitminmax(60px1fr))gap10pxmargin-bottom12px.color-customdisplayflexalign-itemscentergap10pxmargin-bottom12px.color-custom input[type="color"]width42pxheight32pxborder1px solid var(--border)border-radius8pxbackgroundvar(--card-bg)padding0.color-swatchheight44pxborder-radius12pxborder2px solid var(--border)cursorpointerbox-shadowinset 0 0 0 2px rgba(2552552550.55)transitiontransform 0.15s easebox-shadow 0.15s ease.color-swatchhovertransformtranslateY(-1px) scale(1.02)box-shadowinset 0 0 0 2px rgba(2552552550.8).color-swatchactivetransformscale(0.98).color-swatch.customdisplayflexalign-itemscenterjustify-contentcenterfont-weight700colorvar(--text)backgroundlinear-gradient(45deg#f4ead8#d6cabc)box-shadowinset 0 0 0 2px rgba(0000.08).save-overlaypositionfixedinset0backgroundrgba(4435250.55)displaynonealign-itemscenterjustify-contentcenterz-index210padding20pxbackdrop-filterblur(2px).save-overlay.activedisplayflex.save-modalbackgroundvar(--card-bg)colorvar(--text)border-radius16pxpadding18px 18px 14pxwidthmin(420px90vw)box-shadow0 10px 30px rgba(0000.25)border1px solid var(--border).save-subtextmargin0 0 8pxcolorvar(--text-muted).save-listdisplaygridgap8pxmax-height220pxoverflow-yautomargin-bottom10px.save-itemdisplayflexalign-itemscentergap10pxpadding8px 10pxborder-radius10pxbackgroundvar(--input-bg)border1px solid var(--border).save-item inputwidth18pxheight18px.save-actionsdisplayflexjustify-contentflex-endgap8px.icon-woodlandwidth22pxheight22px.containermax-width1200pxmargin0 autopadding1.5rem 1.25rem calc(120px + env(safe-area-inset-bottom0px))positionrelativez-index1flex1.projects-griddisplaygridgrid-template-columnsrepeat(auto-fitminmax(320px1fr))gap20pxalign-itemsstart@media (min-width1280px).projects-gridgrid-template-columnsrepeat(3minmax(320px1fr)).project-containerbackgroundvar(--card-bg)border-radius18pxpadding5px 15px 15px 15pxmargin-bottom2rembox-shadowvar(--shadow)transitionbackground-color 0.3sborder1px solid var(--border)z-index10.project-headerdisplayflexjustify-contentspace-betweenalign-itemscenterpadding15px 5pxmargin-bottom10pxgap10px.project-title-groupdisplayflexalign-itemscentergap10px.project-titlefont-size1.4remfont-weight800colorvar(--project-color)text-transformuppercaseletter-spacing1pxfont-family'Cormorant Garamond'Georgiaserif.btn-toggle-projectbackgroundnonebordernonecolorvar(--project-color)font-size1.2remcursorpointertransitiontransform 0.2spadding5px.part-listmax-height2000pxopacity1transitionmax-height 0.35s easeopacity 0.3s easeoverflowhidden.project-collapsed .btn-toggle-projecttransformrotate(-90deg).project-collapsed .part-listmax-height0opacity0pointer-eventsnone.project-collapsedmargin-bottom1remopacity0.8box-shadownone.project-actionsdisplayflexgap8pxalign-itemscenter.btn-add-partbackgroundvar(--project-color)colorvar(--card-bg)bordernoneborder-radius20pxpadding6px 16pxfont-size0.9remfont-weightboldcursorpointerbox-shadow0 3px 8px rgba(0000.1)transitiontransform 0.1s.btn-add-partactivetransformscale(0.95).btn-save-patternbackgroundnoneborder1px dashed var(--border)colorvar(--text-muted)border-radius14pxpadding6px 10pxfont-size0.9remcursorpointer.btn-save-patternhovercolorvar(--project-color)border-colorvar(--project-color).btn-delete-projectbackgroundnonebordernonecolorvar(--text-muted)font-size1.2remcursorpointerpadding5px.btn-delete-projecthovercolorvar(--danger).btn-rename-projectbackgroundnonebordernonecolorvar(--text-muted)padding5px 8pxborder-radius8pxfont-size1remcursorpointer.btn-rename-projecthovercolorvar(--project-color).icon-pencildisplayinline-blocktransformscaleX(-1).btn-colorwidth28pxheight28pxborder-radius50%border2px solid var(--border)backgroundvar(--project-color)cursorpointerpadding0displayinline-flexalign-itemscenterjustify-contentcenterbox-shadowinset 0 0 0 2px var(--card-bg).btn-colorhoverbox-shadowinset 0 0 0 2px var(--project-color).part-cardbackgroundvar(--card-bg)border-radius14pxpadding1remmargin-bottom0.8rembox-shadowvar(--shadow)positionrelativetransitionbackground-color 0.3soverflowhiddenborder-left7px solid var(--project-color)border-top1px solid var(--border)border-right1px solid var(--border)border-bottom1px solid var(--border).part-card.is-lockedbackground-colorvar(--bg)opacity0.9.part-card.is-finishedbackground-colorvar(--bg-finished)border-left-colorvar(--success)opacity0.9.finish-shimmeranimationfinishShimmer 0.8s ease@keyframes finishShimmer0%box-shadowvar(--shadow)0 0 0 0 rgba(122139790.32)60%box-shadowvar(--shadow)0 0 0 16px rgba(122139790)100%box-shadowvar(--shadow)0 0 0 0 rgba(122139790).part-card.is-minimizedpadding0.8rem 1rem.part-card.is-minimized .count-display.part-card.is-minimized .controlsdisplaynone.part-card.is-minimized .part-mini-countdisplayinline-block.part-card.is-minimized .btn-toggle-parttransformrotate(-90deg).part-card.is-minimized .count-subtext.part-card.is-minimized .btn-reset-part.part-card.is-minimized .btn-delete-partdisplaynone.part-headerdisplayflexjustify-contentspace-betweenalign-itemscenter.part-cardnot(.is-minimized) .part-headermargin-bottom15px.part-name-groupdisplayflexalign-itemscentergap12pxflex-grow1.check-containerpositionrelativecursorpointerwidth26pxheight26pxflex-shrink0.check-container inputopacity0cursorpointerheight0width0.checkmarkpositionabsolutetop0left0height26pxwidth26pxbackground-colorvar(--card-bg)border-radius50%border2px solid var(--text-muted)transitionall 0.2s.check-container inputchecked ~ .checkmarkbackground-colorvar(--success)border-colorvar(--success).checkmarkaftercontent""positionabsolutedisplaynone.check-container inputchecked ~ .checkmarkafterdisplayblock.check-container .checkmarkafterleft9pxtop5pxwidth6pxheight12pxbordersolid var(--card-bg)border-width0 2px 2px 0transformrotate(45deg).part-namefont-size1.1remfont-weight700colorvar(--text)border-bottom2px dashed var(--project-color)cursorpointerwhite-spacenowrapoverflowhiddentext-overflowellipsismax-width160px.part-mini-countdisplaynonefont-weight800colorvar(--project-color)margin-left8pxfont-size1.2rem.is-finished .part-nametext-decorationline-throughcolorvar(--success)border-bottomnone.is-finished .part-mini-countcolorvar(--success).part-actionsdisplayflexgap8pxalign-itemscenter.part-actionsjustify-contentflex-endmin-width120px.icon-btnbackgroundnonebordernonefont-size1.3rempadding5pxcolorvar(--text-muted)cursorpointertransitioncolor 0.2sdisplayinline-flexalign-itemscenterjustify-contentcenterwidth32pxheight32px.icon-btndisabledopacity0.4pointer-eventsnone.btn-delete-parthovercolorvar(--danger).btn-toggle-parttransitiontransform 0.2s.count-displayfont-size3.5remfont-weight800text-aligncentercolorvar(--project-color)margin0.5rem 0 0.4rem 0touch-actionmanipulationtext-shadow1px 1px 0px var(--card-bg).is-locked .count-displaycolorvar(--lock-btn-text)pointer-eventsnone.is-finished .count-displaycolorvar(--success)pointer-eventsnone.count-subtexttext-aligncenterfont-size0.9remcolorvar(--text-muted)margin0 0 0.6rem 0.count-subtext strongcolorvar(--project-color).count-bump-upanimationcountUp 0.26s ease.count-bump-downanimationcountDown 0.26s ease@keyframes countUp0%transformscale(0.95)colorvar(--btn-secondary-text)60%transformscale(1.08)colorvar(--project-color)100%transformscale(1)colorvar(--project-color)@keyframes countDown0%transformscale(1.05)colorvar(--project-color)60%transformscale(0.9)colorvar(--text-muted)100%transformscale(1)colorvar(--project-color).note-togglebackgroundnoneborder1px dashed var(--border)colorvar(--text-muted)padding4px 10pxborder-radius10pxcursorpointerfont-size0.9remmargin12px auto 6pxdisplayblock.note-togglehovercolorvar(--project-color)border-colorvar(--project-color).note-areadisplaynonemargin-top10px.note-area.showdisplayblock.note-area textareawidth100%min-height90pxborder-radius10pxborder1px solid var(--border)backgroundvar(--input-bg)colorvar(--text)padding10pxfont-size0.95remresizevertical.controlsdisplaygridgrid-template-columns1fr 1fr 1frgap10pxpadding0 5pxbutton.action-btnbordernoneborder-radius12pxpadding12px 0font-size1.5remcursorpointerdisplayflexalign-itemscenterjustify-contentcentertransitionall 0.1sbox-shadow0 3px 6px rgba(0000.05).btn-minusbackground-colorvar(--btn-secondary-bg)colorvar(--btn-secondary-text)border1px solid var(--border).btn-plusbackground-colorvar(--project-color)colorvar(--card-bg).btn-lockbackground-colorvar(--lock-btn-bg)colorvar(--lock-btn-text)font-size1.1rem.btn-lock.locked-activebackground-colorvar(--border)colorvar(--text-muted)border2px solid var(--text-muted)box-shadownone.hidden-controlsvisibilityhiddenpointer-eventsnone.dimmedopacity0.4pointer-eventsnonebuttonactivetransformscale(0.97)box-shadownone.fabpositionfixedbottom30pxright30pxbackground-colorvar(--text)colorvar(--bg)width65pxheight65pxborder-radius50%displayflexalign-itemscenterjustify-contentcenterfont-size2.2rembox-shadow0 6px 15px rgba(7459420.3)bordernonez-index3transitiontransform 0.2s easebox-shadow 0.2s ease.fabhovertransformtranslateY(-3px) scale(1.04) rotate(-2deg)box-shadow0 10px 24px rgba(0000.18).fabactivetransformtranslateY(0) scale(0.94)box-shadow0 3px 8px rgba(0000.2).fabz-index2.modal-overlaypositionfixedtop0left0width100%height100%background-colorrgba(4435250.6)z-index200align-itemscenterjustify-contentcenterbackdrop-filterblur(2px)displayflexopacity0visibilityhiddenpointer-eventsnonetransitionopacity 0.3s easevisibility 0.3s ease.modal-overlay.activeopacity1visibilityvisiblepointer-eventsauto.modal-contentbackgroundvar(--modal-bg)colorvar(--text)padding25pxborder-radius20pxwidth85%max-width400pxanimationnonebox-shadowvar(--shadow)border1px solid var(--border)transform-origincenter.modal-overlay.active .modal-contentanimationmodalIn 0.32s ease@keyframes modalIn0%transformtranslateY(10px) scale(0.96)opacity060%transformtranslateY(-4px) scale(1.02)opacity1100%transformtranslateY(0) scale(1)opacity1.modal-titlemargin0 0 15px 0font-size1.3remcolorvar(--text)font-weight800.modal-inputwidth100%padding15pxfont-size1.2rembackgroundvar(--input-bg)colorvar(--text)border2px solid var(--input-border)border-radius12pxmargin-bottom25pxoutlinenonetransitionborder-color 0.2s.modal-inputfocusborder-colorvar(--project-color).modal-actionsdisplayflexjustify-contentflex-endgap10px.modal-btnpadding12px 24pxbordernoneborder-radius10pxfont-size1remfont-weight600cursorpointer.btn-cancelbackgroundvar(--lock-btn-bg)colorvar(--text-muted).btn-savebackgroundvar(--text)colorvar(--bg).pattern-pickerdisplaynonemargin12px 0 6px.pattern-picker labeldisplayblockfont-size0.9remcolorvar(--text-muted)margin-bottom4px.pattern-picker selectwidth100%padding10px 12pxborder-radius10pxborder1px solid var(--border)backgroundvar(--input-bg)colorvar(--text)font-familyinherit.empty-statetext-aligncentercolorvar(--text-muted)margin-top80pxfont-size1.2remfont-styleitalic.footer-bgpositionfixedleft0right0bottom-180pxheightclamp(760px1200px1200px)backgroundurl('textures/mushroom.svg') no-repeat center bottombackground-sizecoveropacity1width125vwpointer-eventsnonez-index0.firefly-wrappositionfixedwidth28pxheight28pxanimationfireflyGlide var(--fly-duration14s) linear forwardsz-index0pointer-eventsnone.fireflywidth100%height100%border-radius50%backgroundradial-gradient(circlergba(2552452000.9) 0%rgba(2552452000.4) 45%rgba(2552452000.1) 70%rgba(2552452000) 85%)opacity0.9mix-blend-modescreenfilterblur(1px) drop-shadow(0 0 14px rgba(2552452000.8)) brightness(1.15)animationfireflyFlutter 1.6s ease-in-out infinite alternatefireflyFlicker 1.1s ease-in-out infinite alternate.seed-wrappositionfixedwidth36pxheight36pxanimationseedGlide var(--seed-duration16s) linear forwardsz-index0pointer-eventsnone.seedwidth200pxheight200pxbackgroundurl('textures/seed.svg') no-repeat center/containopacityvar(--seed-opacity0.07)mix-blend-modescreenfilterdrop-shadow(0 4px 10px rgba(2001901600.55))animationseedDrift 2.6s ease-in-out infinite alternateseedFlicker 1.4s ease-in-out infinite alternateseedFlip var(--seed-flip-duration6s) ease-in-out infinite@keyframes fireflyGlide0%transformtranslate3d(var(--fly-start-x-10vw)var(--fly-start-y0)0) scale(var(--fly-scale1))opacity012%opacity0.5535%transformtranslate3d(var(--fly-mid-x25vw)var(--fly-mid-y-6px)0) scale(var(--fly-scale1))opacity0.6565%transformtranslate3d(var(--fly-mid2-x65vw)var(--fly-mid2-y6px)0) scale(var(--fly-scale1))opacity0.5590%opacity0.35100%transformtranslate3d(var(--fly-end-x110vw)var(--fly-end-y0)0) scale(var(--fly-scale1))opacity0@keyframes fireflyFlutter0%transformtranslateY(-2px) translateX(-3px) scale(0.95)50%transformtranslateY(3px) translateX(2px) scale(1.05)100%transformtranslateY(-2px) translateX(1px) scale(0.97)@keyframes fireflyFlicker0%opacity0.65filterblur(1px) drop-shadow(0 0 10px rgba(2552452000.65))50%opacity1filterblur(0.5px) drop-shadow(0 0 16px rgba(2552452001))100%opacity0.75filterblur(1px) drop-shadow(0 0 12px rgba(2552452000.75))@keyframes seedGlide0%transformtranslate3d(var(--seed-start-12vw)00) scale(var(--seed-scale1))opacity010%opacity0.3540%transformtranslate3d(calc(var(--seed-mid30vw))-10px0) scale(var(--seed-scale1))opacity0.570%transformtranslate3d(calc(var(--seed-mid30vw) * 2)8px0) scale(var(--seed-scale1))opacity0.4100%transformtranslate3d(var(--seed-end110vw)-6px0) scale(var(--seed-scale1))opacity0@keyframes seedDrift0%transformtranslateY(-4px) translateX(calc(var(--seed-sway6px) * -1)) rotate(calc(var(--seed-tilt10deg) * -0.6)) scale(0.94)opacity0.6550%transformtranslateY(5px) translateX(calc(var(--seed-sway6px) * 0.8)) rotate(calc(var(--seed-tilt10deg))) scale(1.05)opacity0.9100%transformtranslateY(-3px) translateX(calc(var(--seed-sway6px) * 0.4)) rotate(calc(var(--seed-tilt10deg) * 0.2)) scale(0.97)opacity0.7@keyframes seedFlicker0%opacity0.65filterdrop-shadow(0 2px 4px rgba(2001901600.35))50%opacity0.9filterdrop-shadow(0 4px 8px rgba(2001901600.5))100%opacity0.7filterdrop-shadow(0 3px 6px rgba(2001901600.4))@keyframes seedFlip0%transformrotate(0deg) scale(1)20%transformrotate(8deg) scale(1.02)40%transformrotate(-10deg) scale(0.98)60%transformrotate(14deg) scale(1.03)80%transformrotate(-6deg) scale(0.99)100%transformrotate(0deg) scale(1)@media (max-width768px).footer-bgbottom-40pxheightclamp(260px24vh450px)@media (max-width1024px) and (min-width769px).containerpadding1.5rem 1.25rem calc(140px + env(safe-area-inset-bottom0px)).footer-bgheightclamp(300px28vh480px)bottom-180px.swal-overlaypositionfixedinset0backgroundrgba(4435250.55)displayflexalign-itemscenterjustify-contentcenterz-index300padding20pxanimationoverlayFade 0.22s ease.swal-dialogbackgroundvar(--card-bg)colorvar(--text)border-radius16pxpadding22px 20px 18pxwidthmin(420px90vw)box-shadow0 10px 30px rgba(0000.25)border1px solid var(--border)text-aligncenterpositionrelativeanimationdialogPop 0.28s ease.swal-titlefont-family'Cormorant Garamond'Georgiaseriffont-size1.4remmargin0 0 8px.swal-textmargin0 0 16pxcolorvar(--text-muted).swal-actionsdisplayflexjustify-contentcentergap10px.swal-btnbordernoneborder-radius12pxpadding12px 18pxfont-weight700cursorpointermin-width110pxtransitiontransform 0.08s.swal-btnactivetransformscale(0.97).swal-confirmbackgroundvar(--project-color)colorvar(--card-bg).swal-cancelbackgroundvar(--lock-btn-bg)colorvar(--text).swal-dangerbackgroundvar(--danger)colorvar(--card-bg)@keyframes overlayFadefromopacity0toopacity1@keyframes dialogPop0%transformtranslateY(8px) scale(0.96)opacity060%transformtranslateY(-3px) scale(1.02)opacity1100%transformtranslateY(0) scale(1)opacity1 \ No newline at end of file +:root { + /* --- Woodland Theme (Light) --- */ + --bg: #e6e0d0; /* parchment */ + --card-bg: #f9f4e6; /* cream */ + --header-bg: #8a6b52; /* warm oak */ + --header-text: #fdf8f0; /* linen */ + --text: #433628; /* cocoa */ + --text-muted: #7a6d5c; + --border: #d6cabc; + --shadow: 0 4px 10px rgba(67, 54, 40, 0.12); + --seed-opacity: 0.06; + + + --modal-bg: #f8f5e6; + --input-bg: #fffdf5; + --input-border: #d1c7b7; + + --bg-finished: #dbe4ce; /* Muted sage green */ + --lock-btn-bg: #ebe7da; + --lock-btn-text: #9c8e7e; + --btn-secondary-bg: #fdfcf5; + --btn-secondary-text: #4a3b2a; + + /* Status Colors (Muted earthy tones) */ + --danger: #b56b54; /* Rusty red/orange */ + --success: #7a8b4f; /* Mossy green */ + + /* Dynamic Project Color (Default Rust) */ + --project-color: #b56b54; +} + +/* --- Woodland Theme (Dark) --- */ +body.dark-mode { + --bg: #2c3327; /* Deep forest background */ + --card-bg: #3b342c; /* Dark wood card */ + --header-bg: #4b3829; /* Darker wood header */ + --header-text: #e9e4d7; + --text: #e9e4d7; /* Cream text */ + --text-muted: #b5aa9b; + --border: #5f5245; + --shadow: 0 4px 10px rgba(0,0,0,0.45); + + --modal-bg: #3d362d; + --input-bg: #2c2720; + --input-border: #594e3f; + + --bg-finished: #2a382a; /* Dark moss */ + --lock-btn-bg: #4a4238; + --lock-btn-text: #8c8276; + --btn-secondary-bg: #4a4238; + --btn-secondary-text: #d1c7b7; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } + +body { + font-family: 'Nunito', "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--bg); + min-height: 100vh; + color: var(--text); + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + overflow-x: hidden; + scrollbar-gutter: stable; /* Prevent layout shift when scrollbar appears/disappears */ + transition: background-color 0.3s, color 0.3s; +} + +body.theme-animating { animation: themeSwap 0.7s ease-in-out; } +@keyframes themeSwap { + 0% { filter: brightness(1.08) saturate(1.06); opacity: 0.85; } + 45% { filter: brightness(1.03) saturate(1.12) contrast(1.04); opacity: 0.92; } + 75% { filter: brightness(0.98) saturate(1.04); opacity: 0.97; } + 100% { filter: brightness(1) saturate(1); opacity: 1; } +} + +header { + background-color: var(--header-bg); + color: var(--header-text); + padding: 0.8rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + position: sticky; + top: 0; + z-index: 100; + border-bottom: 2px solid rgba(0,0,0,0.05); + transition: background-color 0.3s; +} + +h1 { + margin: 0; + font-size: 1.3rem; + font-weight: 800; + letter-spacing: 0.5px; + color: var(--header-text); + text-shadow: 1px 1px 2px rgba(0,0,0,0.1); + font-family: 'Playfair Display', Georgia, serif; + cursor: pointer; + user-select: none; +} + +.brand { display: flex; align-items: center; gap: 10px; } + +.brand-icon { + width: 52px; height: 52px; border-radius: 8px; + object-fit: contain; display: block; + background: rgba(255,255,255,0.12); padding: 4px; + box-shadow: inset 0 1px 2px rgba(0,0,0,0.08); +} + +.header-controls { display: flex; gap: 10px; } + +.header-btn { + background: rgba(255,255,255,0.2); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + cursor: pointer; + color: var(--header-text); + transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.2s; + transform: translateY(0); + font-family: inherit; +} +.header-btn:hover { transform: translateY(-1px) scale(1.03); box-shadow: 0 6px 14px rgba(0,0,0,0.12); } +.header-btn:active { transform: translateY(0) scale(0.96); box-shadow: none; } + +.header-btn.is-active { background: var(--header-text); color: var(--header-bg); } +.hidden { display: none !important; } +.hidden-input { display: none; } + +.fab-pattern { right: auto; left: 22px; background: var(--text); } +.fab-pattern:hover { transform: scale(1.07); } + +/* Pattern Composer Overlay */ +.pattern-overlay { + position: fixed; + inset: 0; + background: rgba(44, 35, 25, 0.55); + backdrop-filter: blur(2px); + display: none; + align-items: center; + justify-content: center; + z-index: 215; + padding: 14px; +} +.pattern-overlay.active { display: flex; } +.pattern-sheet { + width: min(1280px, 96vw); + max-height: 92vh; + overflow: auto; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 16px; + box-shadow: 0 16px 40px rgba(0,0,0,0.25); +} +.pattern-sheet-header { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 10px; + align-items: center; + margin-bottom: 12px; +} +.pattern-sheet-title h2 { margin: 0; font-size: 1.2rem; color: var(--text); } +.pattern-sheet-subtitle { margin: 2px 0 0; color: var(--text-muted); font-size: 0.9rem; } +.pattern-sheet-header h2 { margin: 0; font-size: 1.2rem; color: var(--text); } +.pattern-close { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-muted); + cursor: pointer; +} +.pattern-close:hover { color: var(--project-color); } +.pattern-modes { display: flex; gap: 8px; justify-content: flex-end; } +.pattern-mode { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); padding: 6px 10px; border-radius: 10px; cursor: pointer; } +.pattern-mode.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); } +.pattern-save-indicator { color: var(--text-muted); font-size: 0.9rem; } +.pattern-body { display: grid; gap: 12px; } +.pattern-row-info { color: var(--text-muted); font-size: 0.95rem; } +.pattern-buttons { display: flex; flex-wrap: wrap; gap: 6px; } +.pattern-buttons button { padding: 6px 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); cursor: pointer; color: #fff; } +.pattern-buttons button:hover { border-color: var(--project-color); color: var(--project-color); } +.pattern-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } +.pattern-tab { + background: var(--input-bg); + border: 1px solid var(--border); + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; + color: var(--text); +} +.pattern-tab.is-active { background: var(--project-color); color: var(--card-bg); border-color: var(--project-color); } +.pattern-section { display: none; border: 1px dashed var(--border); padding: 12px; border-radius: 12px; background: var(--card-bg); } +.pattern-section.is-active { display: block; } +.field-label { display: block; margin: 8px 0 4px; color: var(--text-muted); font-size: 0.9rem; } +.pattern-section input, .pattern-section textarea { + width: 100%; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + padding: 10px; + font-family: inherit; +} +.field-group-inline { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: center; } +.pattern-row-editor textarea, +.pattern-output textarea { + width: 100%; + min-height: 60px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--input-bg); + color: #fff; + padding: 10px; + font-family: inherit; +} +.pattern-row-editor { display: grid; gap: 8px; } +.pattern-row-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } +.pattern-row-actions .primary { background: var(--project-color); color: var(--card-bg); border: none; padding: 8px 12px; border-radius: 10px; cursor: pointer; } +.pattern-row-actions .secondary { background: var(--input-bg); color: var(--text); border: 1px solid var(--border); padding: 8px 12px; border-radius: 10px; cursor: pointer; } +.pattern-footer { justify-content: space-between; border-top: 1px solid var(--border); padding-top: 8px; } +.pattern-output { display: grid; gap: 6px; } +.pattern-steps-head { display: flex; align-items: center; justify-content: space-between; } +.pattern-step-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + margin-bottom: 10px; + background: var(--card-bg); + transition: box-shadow 0.2s ease, transform 0.18s ease; +} +.pattern-step-card.card-pop { animation: cardPop 0.28s ease; } +.pattern-step-card:hover { box-shadow: 0 8px 18px rgba(0,0,0,0.08); transform: translateY(-1px); } +.pattern-row-list { display: grid; gap: 6px; } +.row-item { display: grid; grid-template-columns: auto 1fr auto; gap: 6px; align-items: center; } +.row-item label { color: var(--text-muted); font-size: 0.9rem; } +.row-item input { + width: 100%; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); +} +.row-item button { border: 1px solid var(--border); background: var(--btn-secondary-bg); color: var(--text); border-radius: 8px; padding: 4px 8px; cursor: pointer; } +.pattern-step-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; flex-wrap: wrap; } +.add-step-row { display: flex; justify-content: center; padding: 10px; border: 1px dashed var(--border); background: var(--card-bg); } +.add-step-row .add-step-btn { padding: 10px 16px; border-radius: 12px; border: none; background: var(--project-color); color: var(--card-bg); cursor: pointer; font-weight: 700; } +.add-step-row .add-step-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 14px rgba(0,0,0,0.12); } +.add-step-row .add-step-btn:active { transform: translateY(0); box-shadow: none; } +.step-number { + font-weight: 800; + color: var(--project-color); + margin-bottom: 4px; + font-size: 1rem; +} +@keyframes cardPop { + 0% { transform: translateY(6px) scale(0.97); opacity: 0; } + 60% { transform: translateY(-2px) scale(1.01); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} +.abbrev-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.abbrev-group { border: 1px solid var(--border); border-radius: 12px; margin: 6px 0; background: var(--card-bg); } +.abbrev-group[open] > summary { border-bottom: 1px solid var(--border); } +.abbrev-group summary { + cursor: pointer; + padding: 8px 10px; + font-weight: 700; + color: var(--text); + list-style: none; +} +.abbrev-group summary::-webkit-details-marker { display: none; } +.abbrev-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 6px; + padding: 8px 10px 10px; +} +.abbrev-pill { + display: flex; + flex-direction: column; + gap: 4px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-size: 0.92rem; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; +} +.abbrev-pill:hover { transform: translateY(-1px); border-color: var(--project-color); } +.abbrev-pill.is-selected { + background: var(--project-color); + border-color: var(--project-color); + color: var(--card-bg); +} +.abbrev-pill .code { font-weight: 700; color: inherit; } +.abbrev-pill .desc { color: inherit; opacity: 0.85; font-size: 0.85rem; } +.selected-abbrev { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 10px; +} +.selected-abbrev .pill { + background: var(--project-color); + color: var(--card-bg); + padding: 4px 8px; + border-radius: 10px; + font-size: 0.85rem; +} +.color-overlay { + position: fixed; + inset: 0; + background: rgba(44, 35, 25, 0.55); + display: none; + align-items: center; + justify-content: center; + z-index: 210; + padding: 20px; + backdrop-filter: blur(2px); +} +.color-overlay.active { display: flex; } +.color-modal { + background: var(--card-bg); + color: var(--text); + border-radius: 16px; + padding: 18px 18px 14px; + width: min(420px, 90vw); + box-shadow: 0 10px 30px rgba(0,0,0,0.25); + border: 1px solid var(--border); +} +.color-title { margin: 0 0 12px; font-family: 'Playfair Display', Georgia, serif; } +.color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} +.color-custom { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.color-custom input[type="color"] { + width: 42px; + height: 32px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card-bg); + padding: 0; +} +.color-swatch { + height: 44px; + border-radius: 12px; + border: 2px solid var(--border); + cursor: pointer; + box-shadow: inset 0 0 0 2px rgba(255,255,255,0.55); + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.color-swatch:hover { transform: translateY(-1px) scale(1.02); box-shadow: inset 0 0 0 2px rgba(255,255,255,0.8); } +.color-swatch:active { transform: scale(0.98); } +.color-swatch.custom { + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--text); + background: linear-gradient(45deg, #f4ead8, #d6cabc); + box-shadow: inset 0 0 0 2px rgba(0,0,0,0.08); +} + +.save-overlay { + position: fixed; + inset: 0; + background: rgba(44, 35, 25, 0.55); + display: none; + align-items: center; + justify-content: center; + z-index: 210; + padding: 20px; + backdrop-filter: blur(2px); +} +.save-overlay.active { display: flex; } +.save-modal { + background: var(--card-bg); + color: var(--text); + border-radius: 16px; + padding: 18px 18px 14px; + width: min(420px, 90vw); + box-shadow: 0 10px 30px rgba(0,0,0,0.25); + border: 1px solid var(--border); +} +.save-subtext { margin: 0 0 8px; color: var(--text-muted); } +.save-list { display: grid; gap: 8px; max-height: 220px; overflow-y: auto; margin-bottom: 10px; } +.save-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + background: var(--input-bg); + border: 1px solid var(--border); +} +.save-item input { width: 18px; height: 18px; } +.save-actions { display: flex; justify-content: flex-end; gap: 8px; } +.icon-woodland { width: 22px; height: 22px; } + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 1.5rem 1.25rem calc(120px + env(safe-area-inset-bottom, 0px)); + position: relative; + z-index: 1; /* Keep cards above background */ + flex: 1; +} + +.projects-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; + align-items: start; /* Prevent shorter cards from stretching to tallest row mate */ +} + +@media (min-width: 1280px) { + .projects-grid { + grid-template-columns: repeat(3, minmax(320px, 1fr)); + } +} + +/* --- PROJECT CONTAINER --- */ +.project-container { + background: var(--card-bg); + border-radius: 18px; + padding: 5px 15px 15px 15px; + margin-bottom: 2rem; + box-shadow: var(--shadow); + transition: background-color 0.3s; + border: 1px solid var(--border); + z-index: 10; +} + +.project-header { + display: flex; justify-content: space-between; align-items: center; + padding: 15px 5px; margin-bottom: 10px; gap: 10px; +} + +.project-title-group { display: flex; align-items: center; gap: 10px; } + +.project-title { + font-size: 1.4rem; font-weight: 800; color: var(--project-color); + text-transform: uppercase; letter-spacing: 1px; + font-family: 'Playfair Display', Georgia, serif; +} + +.btn-toggle-project { + background: none; border: none; color: var(--project-color); + font-size: 1.2rem; cursor: pointer; transition: transform 0.2s; padding: 5px; +} +.part-list { max-height: 2000px; opacity: 1; transition: max-height 0.35s ease, opacity 0.3s ease; overflow: hidden; } +.project-collapsed .btn-toggle-project { transform: rotate(-90deg); } +.project-collapsed .part-list { max-height: 0; opacity: 0; pointer-events: none; } +.project-collapsed { margin-bottom: 1rem; opacity: 0.8; box-shadow: none; } + +.project-actions { display: flex; gap: 8px; align-items: center; } + +.btn-add-part { + background: var(--project-color); color: var(--card-bg); border: none; + border-radius: 20px; padding: 6px 16px; font-size: 0.9rem; font-weight: bold; + cursor: pointer; box-shadow: 0 3px 8px rgba(0,0,0,0.1); transition: transform 0.1s; +} +.btn-add-part:active { transform: scale(0.95); } +.btn-save-pattern { + background: none; border: 1px dashed var(--border); color: var(--text-muted); + border-radius: 14px; padding: 6px 10px; font-size: 0.9rem; cursor: pointer; +} +.btn-save-pattern:hover { color: var(--project-color); border-color: var(--project-color); } + +.btn-delete-project { + background: none; border: none; color: var(--text-muted); font-size: 1.2rem; cursor: pointer; padding: 5px; +} +.btn-delete-project:hover { color: var(--danger); } + +.btn-rename-project { + background: none; border: none; color: var(--text-muted); + padding: 5px 8px; border-radius: 8px; font-size: 1rem; cursor: pointer; +} +.btn-rename-project:hover { color: var(--project-color); } +.icon-pencil { display: inline-block; transform: scaleX(-1); } + +.btn-color { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid var(--border); + background: var(--project-color); + cursor: pointer; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 0 0 2px var(--card-bg); +} +.btn-color:hover { box-shadow: inset 0 0 0 2px var(--project-color); } + +/* --- PART CARD --- */ +.part-card { + background: var(--card-bg); + border-radius: 14px; + padding: 1rem; + margin-bottom: 0.8rem; + box-shadow: var(--shadow); + position: relative; + transition: background-color 0.3s; + overflow: hidden; + border-left: 7px solid var(--project-color); + border-top: 1px solid var(--border); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.part-card.is-locked { background-color: var(--bg); opacity: 0.9; } + +.part-card.is-finished { + background-color: var(--bg-finished); + border-left-color: var(--success); + opacity: 0.9; +} +.finish-shimmer { animation: finishShimmer 0.8s ease; } +@keyframes finishShimmer { + 0% { box-shadow: var(--shadow), 0 0 0 0 rgba(122, 139, 79, 0.32); } + 60% { box-shadow: var(--shadow), 0 0 0 16px rgba(122, 139, 79, 0); } + 100% { box-shadow: var(--shadow), 0 0 0 0 rgba(122, 139, 79, 0); } +} + +.part-card.is-minimized { padding: 0.8rem 1rem; } +.part-card.is-minimized .count-display, +.part-card.is-minimized .controls { display: none; } +.part-card.is-minimized .part-mini-count { display: inline-block; } +.part-card.is-minimized .btn-toggle-part { transform: rotate(-90deg); } +.part-card.is-minimized .count-subtext, +.part-card.is-minimized .btn-reset-part, +.part-card.is-minimized .btn-delete-part { display: none; } + +.part-header { display: flex; justify-content: space-between; align-items: center; } +.part-card:not(.is-minimized) .part-header { margin-bottom: 15px; } + +.part-name-group { display: flex; align-items: center; gap: 12px; flex-grow: 1; } + +.check-container { position: relative; cursor: pointer; width: 26px; height: 26px; flex-shrink: 0; } +.check-container input { opacity: 0; cursor: pointer; height: 0; width: 0; } +.checkmark { + position: absolute; top: 0; left: 0; height: 26px; width: 26px; + background-color: var(--card-bg); border-radius: 50%; border: 2px solid var(--text-muted); transition: all 0.2s; +} +.check-container input:checked ~ .checkmark { background-color: var(--success); border-color: var(--success); } +.checkmark:after { content: ""; position: absolute; display: none; } +.check-container input:checked ~ .checkmark:after { display: block; } +.check-container .checkmark:after { + left: 9px; top: 5px; width: 6px; height: 12px; + border: solid var(--card-bg); border-width: 0 2px 2px 0; transform: rotate(45deg); +} + +.part-name { + font-size: 1.1rem; font-weight: 700; color: var(--text); + border-bottom: 2px dashed var(--project-color); /* Dashed looks more like stitching */ + cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; +} + +.part-mini-count { + display: none; font-weight: 800; color: var(--project-color); margin-left: 8px; font-size: 1.2rem; +} + +.is-finished .part-name { text-decoration: line-through; color: var(--success); border-bottom: none; } +.is-finished .part-mini-count { color: var(--success); } + +.part-actions { display: flex; gap: 8px; align-items: center; } +.part-actions { justify-content: flex-end; min-width: 120px; } +.icon-btn { + background: none; border: none; font-size: 1.3rem; padding: 5px; color: var(--text-muted); + cursor: pointer; transition: color 0.2s; + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; +} +.icon-btn:disabled { opacity: 0.4; pointer-events: none; } +.btn-delete-part:hover { color: var(--danger); } +.btn-toggle-part { transition: transform 0.2s; } + +.count-display { + font-size: 3.5rem; font-weight: 800; text-align: center; color: var(--project-color); margin: 0.5rem 0 0.4rem 0; touch-action: manipulation; + text-shadow: 1px 1px 0px var(--card-bg); +} +.is-locked .count-display { color: var(--lock-btn-text); pointer-events: none; } +.is-finished .count-display { color: var(--success); pointer-events: none; } + +.count-subtext { + text-align: center; + font-size: 0.9rem; + color: var(--text-muted); + margin: 0 0 0.6rem 0; +} +.count-subtext strong { color: var(--project-color); } +.count-bump-up { animation: countUp 0.26s ease; } +.count-bump-down { animation: countDown 0.26s ease; } +@keyframes countUp { + 0% { transform: scale(0.95); color: var(--btn-secondary-text); } + 60% { transform: scale(1.08); color: var(--project-color); } + 100% { transform: scale(1); color: var(--project-color); } +} +@keyframes countDown { + 0% { transform: scale(1.05); color: var(--project-color); } + 60% { transform: scale(0.9); color: var(--text-muted); } + 100% { transform: scale(1); color: var(--project-color); } +} + +.note-toggle { + background: none; border: 1px dashed var(--border); color: var(--text-muted); + padding: 4px 10px; border-radius: 10px; cursor: pointer; font-size: 0.9rem; + margin: 12px auto 6px; + display: block; +} +.note-toggle:hover { color: var(--project-color); border-color: var(--project-color); } + +.note-area { + display: none; + margin-top: 10px; +} +.note-area.show { display: block; } +.note-area textarea { + width: 100%; + min-height: 90px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + padding: 10px; + font-size: 0.95rem; + resize: vertical; +} +.controls { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; padding: 0 5px; } + +button.action-btn { + border: none; border-radius: 12px; padding: 12px 0; font-size: 1.5rem; + cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.1s; + box-shadow: 0 3px 6px rgba(0,0,0,0.05); +} + +.btn-minus { background-color: var(--btn-secondary-bg); color: var(--btn-secondary-text); border: 1px solid var(--border); } +.btn-plus { background-color: var(--project-color); color: var(--card-bg); } +.btn-lock { background-color: var(--lock-btn-bg); color: var(--lock-btn-text); font-size: 1.1rem; } +.btn-lock.locked-active { background-color: var(--border); color: var(--text-muted); border: 2px solid var(--text-muted); box-shadow: none;} + +.hidden-controls { visibility: hidden; pointer-events: none; } +.dimmed { opacity: 0.4; pointer-events: none; } +button:active { transform: scale(0.97); box-shadow: none; } + +/* Floating Add Project Button */ +.fab { + position: fixed; bottom: 30px; right: 30px; + background-color: var(--text); color: var(--bg); /* Adapts to mode */ + width: 65px; height: 65px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 2.2rem; box-shadow: 0 6px 15px rgba(74, 59, 42, 0.3); + border: none; z-index: 3; transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.fab:hover { transform: translateY(-3px) scale(1.04) rotate(-2deg); box-shadow: 0 10px 24px rgba(0,0,0,0.18); } +.fab:active { transform: translateY(0) scale(0.94); box-shadow: 0 3px 8px rgba(0,0,0,0.2); } +.fab { z-index: 2; } + +/* --- MODAL --- */ +.modal-overlay { + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background-color: rgba(44, 35, 25, 0.6); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(2px); + display: flex; opacity: 0; visibility: hidden; pointer-events: none; + transition: opacity 0.3s ease, visibility 0.3s ease; +} +.modal-overlay.active { opacity: 1; visibility: visible; pointer-events: auto; } +.modal-content { + background: var(--modal-bg); color: var(--text); + padding: 25px; border-radius: 20px; width: 85%; max-width: 400px; + animation: none; box-shadow: var(--shadow); border: 1px solid var(--border); + transform-origin: center; +} +.modal-overlay.active .modal-content { animation: modalIn 0.32s ease; } +@keyframes modalIn { + 0% { transform: translateY(10px) scale(0.96); opacity: 0; } + 60% { transform: translateY(-4px) scale(1.02); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} +.modal-title { margin: 0 0 15px 0; font-size: 1.3rem; color: var(--text); font-weight: 800; } +.modal-input { + width: 100%; padding: 15px; font-size: 1.2rem; + background: var(--input-bg); color: var(--text); + border: 2px solid var(--input-border); border-radius: 12px; margin-bottom: 25px; outline: none; transition: border-color 0.2s; +} +.modal-input:focus { border-color: var(--project-color); } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } +.modal-btn { padding: 12px 24px; border: none; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; } +.btn-cancel { background: var(--lock-btn-bg); color: var(--text-muted); } +.btn-save { background: var(--text); color: var(--bg); } +.pattern-picker { display: none; margin: 12px 0 6px; } +.pattern-picker label { + display: block; + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 4px; +} +.pattern-picker select { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--input-bg); + color: var(--text); + font-family: inherit; +} + +.empty-state { text-align: center; color: var(--text-muted); margin-top: 80px; font-size: 1.2rem; font-style: italic;} + +.footer-bg { + position: fixed; + left: 0; + right: 0; + bottom: -180px; + height: clamp(760px, 1200px, 1200px); + background: url('textures/mushroom.svg') no-repeat center bottom; + background-size: cover; + opacity: 1; + width: 125vw; + pointer-events: none; + z-index: 0; +} + +.firefly-wrap { + position: fixed; + width: 28px; + height: 28px; + animation: fireflyGlide var(--fly-duration, 14s) linear forwards; + z-index: 0; /* Drift beneath page content */ + pointer-events: none; +} +.firefly { + width: 100%; + height: 100%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255,245,200,0.9) 0%, rgba(255,245,200,0.4) 45%, rgba(255,245,200,0.1) 70%, rgba(255,245,200,0) 85%); + opacity: 0.9; + mix-blend-mode: screen; + filter: blur(1px) drop-shadow(0 0 14px rgba(255, 245, 200, 0.8)) brightness(1.15); + animation: fireflyFlutter 1.6s ease-in-out infinite alternate, fireflyFlicker 1.1s ease-in-out infinite alternate; +} + +.seed-wrap { + position: fixed; + width: 36px; + height: 36px; + animation: seedGlide var(--seed-duration, 16s) linear forwards; + z-index: 0; + pointer-events: none; +} +.seed { + width: 200px; + height: 200px; + background: url('textures/seed.svg') no-repeat center/contain; + opacity: var(--seed-opacity, 0.07); + mix-blend-mode: screen; + filter: drop-shadow(0 4px 10px rgba(200, 190, 160, 0.55)); + animation: seedDrift 2.6s ease-in-out infinite alternate, + seedFlicker 1.4s ease-in-out infinite alternate, + seedFlip var(--seed-flip-duration, 6s) ease-in-out infinite; +} +@keyframes fireflyGlide { + 0% { transform: translate3d(var(--fly-start-x, -10vw), var(--fly-start-y, 0), 0) scale(var(--fly-scale, 1)); opacity: 0; } + 12% { opacity: 0.55; } + 35% { transform: translate3d(var(--fly-mid-x, 25vw), var(--fly-mid-y, -6px), 0) scale(var(--fly-scale, 1)); opacity: 0.65; } + 65% { transform: translate3d(var(--fly-mid2-x, 65vw), var(--fly-mid2-y, 6px), 0) scale(var(--fly-scale, 1)); opacity: 0.55; } + 90% { opacity: 0.35; } + 100% { transform: translate3d(var(--fly-end-x, 110vw), var(--fly-end-y, 0), 0) scale(var(--fly-scale, 1)); opacity: 0; } +} +@keyframes fireflyFlutter { + 0% { transform: translateY(-2px) translateX(-3px) scale(0.95); } + 50% { transform: translateY(3px) translateX(2px) scale(1.05); } + 100% { transform: translateY(-2px) translateX(1px) scale(0.97); } +} +@keyframes fireflyFlicker { + 0% { opacity: 0.65; filter: blur(1px) drop-shadow(0 0 10px rgba(255,245,200,0.65)); } + 50% { opacity: 1; filter: blur(0.5px) drop-shadow(0 0 16px rgba(255,245,200,1)); } + 100% { opacity: 0.75; filter: blur(1px) drop-shadow(0 0 12px rgba(255,245,200,0.75)); } +} + +@keyframes seedGlide { + 0% { transform: translate3d(var(--seed-start, -12vw), 0, 0) scale(var(--seed-scale, 1)); opacity: 0; } + 10% { opacity: 0.35; } + 40% { transform: translate3d(calc(var(--seed-mid, 30vw)), -10px, 0) scale(var(--seed-scale, 1)); opacity: 0.5; } + 70% { transform: translate3d(calc(var(--seed-mid, 30vw) * 2), 8px, 0) scale(var(--seed-scale, 1)); opacity: 0.4; } + 100% { transform: translate3d(var(--seed-end, 110vw), -6px, 0) scale(var(--seed-scale, 1)); opacity: 0; } +} +@keyframes seedDrift { + 0% { transform: translateY(-4px) translateX(calc(var(--seed-sway, 6px) * -1)) rotate(calc(var(--seed-tilt, 10deg) * -0.6)) scale(0.94); opacity: 0.65; } + 50% { transform: translateY(5px) translateX(calc(var(--seed-sway, 6px) * 0.8)) rotate(calc(var(--seed-tilt, 10deg))) scale(1.05); opacity: 0.9; } + 100% { transform: translateY(-3px) translateX(calc(var(--seed-sway, 6px) * 0.4)) rotate(calc(var(--seed-tilt, 10deg) * 0.2)) scale(0.97); opacity: 0.7; } +} +@keyframes seedFlicker { + 0% { opacity: 0.65; filter: drop-shadow(0 2px 4px rgba(200,190,160,0.35)); } + 50% { opacity: 0.9; filter: drop-shadow(0 4px 8px rgba(200,190,160,0.5)); } + 100% { opacity: 0.7; filter: drop-shadow(0 3px 6px rgba(200,190,160,0.4)); } +} +@keyframes seedFlip { + 0% { transform: rotate(0deg) scale(1); } + 20% { transform: rotate(8deg) scale(1.02); } + 40% { transform: rotate(-10deg) scale(0.98); } + 60% { transform: rotate(14deg) scale(1.03); } + 80% { transform: rotate(-6deg) scale(0.99); } + 100% { transform: rotate(0deg) scale(1); } +} + +@media (max-width: 768px) { + .footer-bg { + bottom: -40px; + height: clamp(260px, 24vh, 450px); + } +} + +@media (max-width: 1024px) and (min-width: 769px) { + .container { + padding: 1.5rem 1.25rem calc(140px + env(safe-area-inset-bottom, 0px)); + } + .footer-bg { + height: clamp(300px, 28vh, 480px); + bottom: -180px; + } +} + + +/* Sweet-ish alerts */ +.swal-overlay { + position: fixed; + inset: 0; + background: rgba(44, 35, 25, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + padding: 20px; + animation: overlayFade 0.22s ease; +} + +.swal-dialog { + background: var(--card-bg); + color: var(--text); + border-radius: 16px; + padding: 22px 20px 18px; + width: min(420px, 90vw); + box-shadow: 0 10px 30px rgba(0,0,0,0.25); + border: 1px solid var(--border); + text-align: center; + position: relative; + animation: dialogPop 0.28s ease; +} + +.swal-title { + font-family: 'Playfair Display', Georgia, serif; + font-size: 1.4rem; + margin: 0 0 8px; +} + +.swal-text { margin: 0 0 16px; color: var(--text-muted); } + +.swal-actions { display: flex; justify-content: center; gap: 10px; } + +.swal-btn { + border: none; + border-radius: 12px; + padding: 12px 18px; + font-weight: 700; + cursor: pointer; + min-width: 110px; + transition: transform 0.08s; +} +.swal-btn:active { transform: scale(0.97); } +.swal-confirm { background: var(--project-color); color: var(--card-bg); } +.swal-cancel { background: var(--lock-btn-bg); color: var(--text); } +.swal-danger { background: var(--danger); color: var(--card-bg); } +@keyframes overlayFade { from { opacity: 0; } to { opacity: 1; } } +@keyframes dialogPop { + 0% { transform: translateY(8px) scale(0.96); opacity: 0; } + 60% { transform: translateY(-3px) scale(1.02); opacity: 1; } + 100% { transform: translateY(0) scale(1); opacity: 1; } +} diff --git a/index.html b/index.html index 675f056..944908c 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Toadstool Cottage Counter - + @@ -35,6 +35,7 @@
+ +
+
+
+
+

Pattern Composer

+

Draft rows plus materials, gauge, and abbreviations.

+
+
+ + +
+
Saved
+ +
+
+
+ + +
+
+ + + + + + +
+
+ + +
+
+ + +
+
+ + + + + + +
+ + +
+
+ + + + + + +
+
+
+

Steps

+ +
+
+
+
+
+
+ +
+
+
+
+

Pattern Composer

+

Draft rows plus materials, gauge, and abbreviations.

+
+
+ + +
+ +
+
+
+ + + + + + + +
+
+ + + + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+
Row 1
+
+ + + + + + + + + + + + +
+
+ +
+ + +
+
+
+

Steps

+ +
+
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+