poetry-core 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc22978b709fc22e859b79edce9929b57a358c7fec1969176f5f3d82e8dacd7a
4
- data.tar.gz: 0765f9ded7f711d893f11dba287c2e4014b05a2906e9a3bb3ecf72e3249d7b94
3
+ metadata.gz: d3a645a256745edb1c315853e3ce88d918362af24218c198a405e66dd2e224f0
4
+ data.tar.gz: 799ffd218002cdbd8f6ed71745f0a152b559e833c0feb7105fa4afdf3907b689
5
5
  SHA512:
6
- metadata.gz: 342c1ca9b06f0945c31deb4dab0e25c56e52d2f2afa9ec6dbda9173f1be20e4df699385d070aeb90bbc81a0d43438476c0e3d4a361ca980ea6f4fb163de670cd
7
- data.tar.gz: a0a1285525f488233c29823543cb111ea2afc18fc1d2342d3ece955f0770538604367131c82f18792d4a6137bcaa9c1e87e12d7a991d6decd23f1484842393a6
6
+ metadata.gz: 7bded4405ebe9a3fdb3b09fac843525522cab4ccb163911889421fe65f94427430d4426d1c5b904a38594b5a863edb6a2f66b543cb5a56c8c3bbaf8aee4b09f3
7
+ data.tar.gz: fb9a1925cacace5469d961f86007fe490da19b8692a69c8061a781885261fb404a705080de32180e8b475064f997953594805b4f08b3251790e88e34b8f7a61b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.13] - 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - Effect behaviors over the per-connect scope, for controllers of your own and the components ported from other registries: `useInterval` and `useTimeout` (timers cleared at teardown), `useInView` (IntersectionObserver, with `once:`; visible at once where the API is missing), `useMeasure` (ResizeObserver, reporting the size now and on change), `useMutation` (MutationObserver, children in the subtree by default) and `useClipboard` (the Clipboard API with a textarea fallback and a copied state that resets). Import them from `@poetry/controllers/behaviors/<name>`; each is undone by `teardown(this)`.
8
+ - `poetry:design:import` warns when a palette makes border or input too faint to see: in each mode the import touches, both are measured against the background, card and popover, and a pair below 1.15:1 is reported with the nearest visible lightness. The colors still ship, since a faint edge is a design choice and not an accessibility floor. A dark palette that gave border, input and card one value hid every bordered control on cards. The JSON report carries the warnings as `edges`.
9
+
10
+ ## [0.1.12] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - Version bump with the family; no changes in this gem.
15
+
3
16
  ## [0.1.11] - 2026-10-05
4
17
 
5
18
  ### Changed
@@ -0,0 +1,61 @@
1
+ // useClipboard: copying text for one connect. The async Clipboard API
2
+ // where the page may use it, else a hidden textarea and execCommand; the
3
+ // copied flag resets after `resetAfter` and its timer clears at teardown.
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * A copier for the controller's current connect.
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {{ resetAfter?: number, onChange?: (copied: boolean) => void }} [options]
11
+ * how long the copied state lasts, in milliseconds, and who hears it change
12
+ * @returns {{ copy: (text: string) => Promise<boolean> }} copy resolves true when the text reached the clipboard
13
+ */
14
+ export function useClipboard(controller, { resetAfter = 2000, onChange = () => {} } = {}) {
15
+ const scope = scopeOf(controller)
16
+ let timer = null
17
+ scope.defer(() => clearTimeout(timer))
18
+
19
+ const copy = async (text) => {
20
+ const copied = await write(text)
21
+ if (!copied) return false
22
+
23
+ clearTimeout(timer)
24
+ onChange(true)
25
+ timer = setTimeout(() => onChange(false), resetAfter)
26
+ return true
27
+ }
28
+
29
+ return { copy }
30
+ }
31
+
32
+ const write = async (text) => {
33
+ try {
34
+ if (navigator.clipboard?.writeText) {
35
+ await navigator.clipboard.writeText(text)
36
+ return true
37
+ }
38
+ } catch {
39
+ // Denied or unavailable: fall through to the textarea.
40
+ }
41
+
42
+ return fallback(text)
43
+ }
44
+
45
+ const fallback = (text) => {
46
+ const area = document.createElement("textarea")
47
+ area.value = text
48
+ area.setAttribute("readonly", "")
49
+ area.style.position = "fixed"
50
+ area.style.opacity = "0"
51
+ document.body.append(area)
52
+ area.select()
53
+
54
+ try {
55
+ return document.execCommand?.("copy") === true
56
+ } catch {
57
+ return false
58
+ } finally {
59
+ area.remove()
60
+ }
61
+ }
@@ -0,0 +1,36 @@
1
+ // useInView: whether an element is on screen, for one connect, over
2
+ // IntersectionObserver. Reveal-on-scroll and lazy starts read it; the
3
+ // observer disconnects at teardown. Without IntersectionObserver the
4
+ // element counts as visible at once, so content never stays hidden.
5
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
6
+
7
+ /**
8
+ * Reports `element` entering and leaving the viewport (or `root`) until
9
+ * teardown(controller). With `once`, it reports the first entry and stops.
10
+ *
11
+ * @param {Object} controller - the Stimulus controller instance
12
+ * @param {Element} element - the element to watch
13
+ * @param {(inView: boolean, entry?: IntersectionObserverEntry) => void} onChange - the handler
14
+ * @param {{ root?: Element, rootMargin?: string, threshold?: number | number[], once?: boolean }} [options]
15
+ * @returns {() => void} stops watching early; teardown stops it otherwise
16
+ */
17
+ export function useInView(controller, element, onChange, options = {}) {
18
+ const { once = false, ...observe } = options
19
+
20
+ if (typeof IntersectionObserver === "undefined") {
21
+ onChange(true)
22
+ return () => {}
23
+ }
24
+
25
+ let stop = () => {}
26
+ const observer = new IntersectionObserver((entries) => {
27
+ for (const entry of entries) {
28
+ onChange(entry.isIntersecting, entry)
29
+ if (once && entry.isIntersecting) stop()
30
+ }
31
+ }, observe)
32
+ observer.observe(element)
33
+ stop = scopeOf(controller).defer(() => observer.disconnect())
34
+
35
+ return stop
36
+ }
@@ -0,0 +1,39 @@
1
+ // useInterval and useTimeout: timers that live for one connect. The
2
+ // clears are registered for you, so a ticker or an autoplay never fires
3
+ // into a disconnected controller.
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Calls `callback` every `milliseconds` until teardown(controller).
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {() => void} callback - the tick
11
+ * @param {number} milliseconds - the period
12
+ * @returns {() => void} stops the interval early; teardown stops it otherwise
13
+ */
14
+ export function useInterval(controller, callback, milliseconds) {
15
+ const id = setInterval(callback, milliseconds)
16
+
17
+ return scopeOf(controller).defer(() => clearInterval(id))
18
+ }
19
+
20
+ /**
21
+ * Calls `callback` once after `milliseconds`, unless teardown(controller)
22
+ * comes first.
23
+ *
24
+ * @param {Object} controller - the Stimulus controller instance
25
+ * @param {() => void} callback - the delayed call
26
+ * @param {number} milliseconds - the delay
27
+ * @returns {() => void} cancels early; teardown cancels otherwise
28
+ */
29
+ export function useTimeout(controller, callback, milliseconds) {
30
+ const scope = scopeOf(controller)
31
+ let cancel = () => {}
32
+ const id = setTimeout(() => {
33
+ cancel()
34
+ callback()
35
+ }, milliseconds)
36
+ cancel = scope.defer(() => clearTimeout(id))
37
+
38
+ return cancel
39
+ }
@@ -0,0 +1,28 @@
1
+ // useMeasure: an element's size for one connect, over ResizeObserver.
2
+ // The handler runs now with the current size and again on every change;
3
+ // the observer disconnects at teardown. Without ResizeObserver it runs
4
+ // once.
5
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
6
+
7
+ /**
8
+ * Reports `element`'s content box until teardown(controller).
9
+ *
10
+ * @param {Object} controller - the Stimulus controller instance
11
+ * @param {Element} element - the element to measure
12
+ * @param {(size: { width: number, height: number }) => void} onResize - the handler
13
+ * @returns {() => void} stops measuring early; teardown stops it otherwise
14
+ */
15
+ export function useMeasure(controller, element, onResize) {
16
+ const rect = element.getBoundingClientRect()
17
+ onResize({ width: rect.width, height: rect.height })
18
+
19
+ if (typeof ResizeObserver === "undefined") return () => {}
20
+
21
+ const observer = new ResizeObserver((entries) => {
22
+ const box = entries[entries.length - 1]?.contentRect
23
+ if (box) onResize({ width: box.width, height: box.height })
24
+ })
25
+ observer.observe(element)
26
+
27
+ return scopeOf(controller).defer(() => observer.disconnect())
28
+ }
@@ -0,0 +1,20 @@
1
+ // useMutation: changes under an element for one connect, over
2
+ // MutationObserver; the observer disconnects at teardown. The default
3
+ // watches children added and removed anywhere below.
4
+ import { scopeOf } from "@poetry/controllers/helpers/scope"
5
+
6
+ /**
7
+ * Reports mutations under `element` until teardown(controller).
8
+ *
9
+ * @param {Object} controller - the Stimulus controller instance
10
+ * @param {Node} element - the node to observe
11
+ * @param {(records: MutationRecord[]) => void} onMutate - the handler
12
+ * @param {MutationObserverInit} [options] - what to watch; children in the subtree by default
13
+ * @returns {() => void} stops observing early; teardown stops it otherwise
14
+ */
15
+ export function useMutation(controller, element, onMutate, options = { childList: true, subtree: true }) {
16
+ const observer = new MutationObserver(onMutate)
17
+ observer.observe(element, options)
18
+
19
+ return scopeOf(controller).defer(() => observer.disconnect())
20
+ }
@@ -28,6 +28,13 @@ module Poetry
28
28
  # @api private
29
29
  class Import
30
30
  AA = 4.5
31
+ # The contrast a border or input line needs against the surfaces it
32
+ # sits on to be seen. Below it every bordered control on that
33
+ # surface disappears; the import warns and ships anyway, since a
34
+ # faint edge is a design choice, never an accessibility floor.
35
+ EDGE = 1.15
36
+ EDGES = %w[border input].freeze
37
+ SURFACES = %w[background card popover].freeze
31
38
 
32
39
  # Conservative cross-ecosystem role aliases - every application is
33
40
  # itself reported, so nothing maps silently. Names already matching
@@ -62,12 +69,22 @@ module Poetry
62
69
  end
63
70
  end
64
71
 
72
+ # One line too faint against a surface it sits on, with the nearest
73
+ # lightness that clears EDGE.
74
+ Edge = Struct.new(:mode, :edge, :surface, :ratio, :suggestion, keyword_init: true) do
75
+ # The warning as one line: mode, the pair, its ratio and the nearest visible value.
76
+ def to_s
77
+ line = "[#{mode}] #{edge} on #{surface}: #{format("%.2f", ratio)}:1 (visible needs >= #{EDGE})"
78
+ suggestion ? "#{line}; nearest visible: #{suggestion}" : line
79
+ end
80
+ end
81
+
65
82
  # pins: dark-mode values RESTATED from the shipped defaults for every
66
83
  # light-overridden token the source gave no dark value for. Without
67
84
  # them a light-only import's :root block would also win in dark mode
68
85
  # (:root and .dark tie on specificity; the overrides file loads
69
86
  # later) - pinning keeps dark exactly as shipped, no fabrication.
70
- Plan = Struct.new(:overrides, :pins, :radius, :applied, :dropped, :contrast, :typography_note,
87
+ Plan = Struct.new(:overrides, :pins, :radius, :applied, :dropped, :contrast, :edges, :typography_note,
71
88
  keyword_init: true) do
72
89
  # Whether the plan changes a radius or any color.
73
90
  def any_overrides?
@@ -102,7 +119,7 @@ module Poetry
102
119
  end
103
120
 
104
121
  Plan.new(overrides: overrides, pins: dark_pins(overrides), radius: doc["radius"],
105
- applied: applied, dropped: dropped, contrast: contrast,
122
+ applied: applied, dropped: dropped, contrast: contrast, edges: faint_edges(overrides),
106
123
  typography_note: typography_note(doc))
107
124
  end
108
125
 
@@ -114,10 +131,19 @@ module Poetry
114
131
  # @param candidate [Tokens::Color] the color to walk
115
132
  # @param against [Tokens::Color] the fixed other member of the pair
116
133
  # @return [Tokens::Color, nil] the nearest passing color, or nil
117
- def nearest_aa(candidate, against)
134
+ def nearest_aa(candidate, against) = nearest_contrast(candidate, against, AA)
135
+
136
+ # The same walk to any contrast floor: AA for text, EDGE for lines.
137
+ #
138
+ # @param candidate [Tokens::Color] the color to walk
139
+ # @param against [Tokens::Color] the fixed other member of the pair
140
+ # @param floor [Float] the ratio the pair must reach
141
+ # @return [Tokens::Color, nil] the nearest passing color, or nil
142
+ def nearest_contrast(candidate, against, floor)
118
143
  step = candidate.l > against.l ? 0.01 : -0.01
144
+ step = 0.01 if candidate.l == against.l && against.l < 0.5
119
145
  color = candidate
120
- until color.contrast_ratio(against) >= AA
146
+ until color.contrast_ratio(against) >= floor
121
147
  next_l = (color.l + step).round(3)
122
148
  return nil unless next_l.between?(0.0, 1.0)
123
149
 
@@ -166,6 +192,28 @@ module Poetry
166
192
  end
167
193
  end
168
194
 
195
+ # Border and input against every surface, in each mode the import
196
+ # touches one of them: the lines too faint to see (the case of a dark
197
+ # palette giving border, input and card one value).
198
+ def faint_edges(overrides)
199
+ overrides.flat_map do |mode, colors|
200
+ next [] unless colors.keys.intersect?(EDGES + SURFACES)
201
+
202
+ EDGES.product(SURFACES).filter_map do |edge, surface|
203
+ line = merged(mode, edge, overrides)
204
+ fill = merged(mode, surface, overrides)
205
+ next unless line && fill
206
+
207
+ ratio = line.contrast_ratio(fill)
208
+ next if ratio >= EDGE
209
+
210
+ walked = nearest_contrast(line, fill, EDGE)
211
+ Edge.new(mode: mode, edge: edge, surface: surface, ratio: ratio,
212
+ suggestion: walked && "--#{edge}: #{walked.css}")
213
+ end
214
+ end
215
+ end
216
+
169
217
  # Every ledger pair an override touches, measured on the merged set.
170
218
  def enforce_contrast(overrides, force:)
171
219
  Tokens::ContrastGate::LEDGER.flat_map do |mode, specs|
@@ -4,6 +4,6 @@ module Poetry
4
4
  module Core
5
5
  # The gem version. Every gem in the family carries the same version
6
6
  # and pins poetry-core to it exactly.
7
- VERSION = "0.1.11"
7
+ VERSION = "0.1.13"
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: poetry-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Solt
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-05 00:00:00.000000000 Z
10
+ date: 2026-10-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -101,10 +101,15 @@ files:
101
101
  - app/javascript/poetry/core/autocomplete_controller.js
102
102
  - app/javascript/poetry/core/behaviors/announce.js
103
103
  - app/javascript/poetry/core/behaviors/before_cache.js
104
+ - app/javascript/poetry/core/behaviors/clipboard.js
104
105
  - app/javascript/poetry/core/behaviors/escape.js
106
+ - app/javascript/poetry/core/behaviors/in_view.js
107
+ - app/javascript/poetry/core/behaviors/interval.js
105
108
  - app/javascript/poetry/core/behaviors/layers.js
106
109
  - app/javascript/poetry/core/behaviors/listen.js
110
+ - app/javascript/poetry/core/behaviors/measure.js
107
111
  - app/javascript/poetry/core/behaviors/mobile.js
112
+ - app/javascript/poetry/core/behaviors/mutation.js
108
113
  - app/javascript/poetry/core/behaviors/overlay.js
109
114
  - app/javascript/poetry/core/behaviors/portal.js
110
115
  - app/javascript/poetry/core/behaviors/presence.js